thingsboard-aplcache
Changes
.gitignore 1(+1 -0)
.travis.yml 1(+1 -0)
application/pom.xml 46(+36 -10)
application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java 681(+413 -268)
application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java 19(+11 -8)
application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java 12(+5 -7)
application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java 82(+45 -37)
application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java 13(+9 -4)
application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java 122(+0 -122)
application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java 180(+0 -180)
application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java 34(+11 -23)
application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java 28(+8 -20)
application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java 2(+1 -1)
application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java 6(+3 -3)
application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java 4(+0 -4)
application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java 9(+1 -8)
application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java 7(+0 -7)
application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java 130(+105 -25)
application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java 9(+8 -1)
application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java 38(+29 -9)
application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java 63(+63 -0)
application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java 39(+27 -12)
application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java 19(+9 -10)
application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java 50(+50 -0)
application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java 23(+11 -12)
application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java 26(+11 -15)
application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java 4(+4 -0)
application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java 5(+2 -3)
application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java 19(+9 -10)
application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java 50(+50 -0)
application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java 25(+9 -16)
application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java 24(+12 -12)
application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java 7(+2 -5)
application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java 111(+0 -111)
application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java 68(+35 -33)
application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java 3(+0 -3)
application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java 102(+102 -0)
application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java 133(+27 -106)
application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java 12(+7 -5)
application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java 211(+211 -0)
application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java 15(+7 -8)
application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java 21(+8 -13)
application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java 19(+14 -5)
application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java 8(+4 -4)
application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java 47(+26 -21)
application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java 50(+50 -0)
application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java 21(+9 -12)
application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java 3(+2 -1)
application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java 50(+43 -7)
application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java 14(+12 -2)
application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java 173(+173 -0)
application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java 214(+214 -0)
application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java 26(+20 -6)
application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java 232(+232 -0)
application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java 109(+109 -0)
application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java 25(+8 -17)
application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java 25(+10 -15)
application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java 14(+7 -7)
application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java 23(+9 -14)
application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java 16(+8 -8)
application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java 14(+7 -7)
application/src/main/proto/jsinvoke.proto 87(+87 -0)
application/src/main/resources/thingsboard.yml 255(+143 -112)
application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java 5(+0 -5)
application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java 533(+533 -0)
application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java 16(+8 -8)
application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java 25(+12 -13)
application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java 32(+24 -8)
application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java 66(+63 -3)
application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java 14(+3 -11)
application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java 72(+2 -70)
application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java 98(+88 -10)
application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java 13(+2 -11)
common/data/pom.xml 2(+1 -1)
common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java 43(+43 -0)
common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java 48(+48 -0)
common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java 31(+17 -14)
common/message/pom.xml 6(+5 -1)
common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java 47(+0 -47)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java 52(+0 -52)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java 63(+0 -63)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java 45(+0 -45)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java 59(+0 -59)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java 40(+0 -40)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java 79(+0 -79)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java 42(+0 -42)
common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java 66(+0 -66)
common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java 11(+1 -10)
common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java 107(+0 -107)
common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java 41(+0 -41)
common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java 68(+0 -68)
common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java 17(+4 -13)
common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java 4(+3 -1)
common/pom.xml 6(+3 -3)
common/queue/pom.xml 99(+99 -0)
common/queue/src/main/resources/logback.xml 35(+35 -0)
common/transport/coap/pom.xml 88(+88 -0)
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java 47(+47 -0)
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java 155(+155 -0)
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java 0(+0 -0)
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java 51(+51 -0)
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java 433(+433 -0)
common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java 40(+8 -32)
common/transport/http/pom.xml 81(+81 -0)
common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java 295(+295 -0)
common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java 27(+15 -12)
common/transport/mqtt/pom.xml 98(+98 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java 217(+217 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java 62(+62 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java 59(+44 -15)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java 63(+63 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java 545(+545 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java 38(+8 -30)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java 58(+10 -48)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java 47(+47 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java 101(+101 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java 375(+375 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java 56(+56 -0)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java 46(+19 -27)
common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java 2(+1 -1)
common/transport/pom.xml 67(+10 -57)
common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java 216(+0 -216)
common/transport/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java 67(+0 -67)
common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java 52(+0 -52)
common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java 68(+0 -68)
common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java 79(+0 -79)
common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java 73(+0 -73)
common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java 52(+0 -52)
common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java 76(+0 -76)
common/transport/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java 66(+0 -66)
common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java 74(+0 -74)
common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java 83(+0 -83)
common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java 65(+0 -65)
common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java 63(+0 -63)
common/transport/transport-api/pom.xml 117(+117 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java 0(+0 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java 459(+459 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java 17(+10 -7)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java 0(+0 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java 0(+0 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java 290(+290 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java 324(+324 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java 47(+47 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java 17(+8 -9)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java 53(+53 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java 17(+10 -7)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java 17(+9 -8)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java 21(+7 -14)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java 21(+10 -11)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java 45(+22 -23)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java 24(+15 -9)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java 3(+0 -3)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java 6(+3 -3)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java 54(+28 -26)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java 74(+74 -0)
common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java 14(+7 -7)
dao/pom.xml 2(+1 -1)
dao/src/main/resources/cassandra/schema-entities.cql 153(+113 -40)
dao/src/main/resources/cassandra/system-data.cql 240(+1 -239)
dao/src/main/resources/sql/schema-entities.sql 41(+16 -25)
dao/src/main/resources/sql/schema-ts.sql 39(+39 -0)
dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java 73(+71 -2)
docker/.env 25(+15 -10)
docker/.gitignore 7(+7 -0)
docker/check-dirs.sh 19(+6 -13)
docker/compose-utils.sh 50(+50 -0)
docker/docker-compose.cassandra.yml 40(+40 -0)
docker/docker-compose.postgres.volumes.yml 36(+36 -0)
docker/docker-compose.postgres.yml 42(+42 -0)
docker/docker-compose.yml 175(+150 -25)
docker/docker-install-tb.sh 56(+56 -0)
docker/docker-remove-services.sh 20(+8 -12)
docker/docker-start-services.sh 21(+9 -12)
docker/docker-stop-services.sh 18(+7 -11)
docker/docker-update-service.sh 26(+9 -17)
docker/docker-upgrade-tb.sh 55(+55 -0)
docker/haproxy/config/haproxy.cfg 107(+107 -0)
docker/kafka.env 12(+12 -0)
docker/README.md 95(+95 -0)
docker/tb-coap-transport.env 6(+6 -0)
docker/tb-http-transport.env 6(+6 -0)
docker/tb-js-executor.env 7(+7 -0)
docker/tb-mqtt-transport.env 6(+6 -0)
docker/tb-node.cassandra.env 5(+5 -0)
docker/tb-node.env 10(+10 -0)
docker/tb-node.postgres.env 9(+9 -0)
docker/tb-node/conf/logback.xml 51(+51 -0)
docker/tb-node/conf/thingsboard.conf 24(+24 -0)
docker/tb-web-ui.env 9(+9 -0)
msa/black-box-tests/pom.xml 110(+110 -0)
msa/black-box-tests/README.md 23(+23 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java 206(+206 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java 57(+57 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java 400(+400 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java 119(+119 -0)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java 12(+7 -5)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java 36(+19 -17)
msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java 110(+110 -0)
msa/js-executor/.gitignore 32(+32 -0)
msa/js-executor/api/jsExecutor.js 48(+48 -0)
msa/js-executor/api/jsInvokeMessageProcessor.js 226(+226 -0)
msa/js-executor/api/utils.js 34(+18 -16)
msa/js-executor/build.gradle 120(+120 -0)
msa/js-executor/config/default.yml 26(+26 -0)
msa/js-executor/config/logger.js 59(+59 -0)
msa/js-executor/config/tb-js-executor.conf 13(+3 -10)
msa/js-executor/docker/Dockerfile 28(+28 -0)
msa/js-executor/docker/start-js-executor.sh 29(+29 -0)
msa/js-executor/install.js 42(+42 -0)
msa/js-executor/package.json 39(+39 -0)
msa/js-executor/pom.xml 399(+399 -0)
msa/js-executor/server.js 105(+105 -0)
msa/js-executor/src/main/scripts/init/tb-js-executor 233(+233 -0)
msa/pom.xml 59(+59 -0)
msa/tb/docker/install-tb.sh 56(+56 -0)
msa/tb/docker/logback.xml 51(+51 -0)
msa/tb/docker/start-tb.sh 39(+39 -0)
msa/tb/docker/thingsboard.conf 24(+24 -0)
msa/tb/docker/upgrade-tb.sh 47(+47 -0)
msa/tb/docker-cassandra/Dockerfile 59(+59 -0)
msa/tb/docker-cassandra/start-db.sh 51(+15 -36)
msa/tb/docker-cassandra/stop-db.sh 9(+2 -7)
msa/tb/docker-postgres/Dockerfile 62(+62 -0)
msa/tb/docker-postgres/start-db.sh 30(+30 -0)
msa/tb/docker-postgres/stop-db.sh 10(+2 -8)
msa/tb/docker-tb/Dockerfile 48(+48 -0)
msa/tb/docker-tb/start-db.sh 10(+2 -8)
msa/tb/docker-tb/stop-db.sh 12(+1 -11)
msa/tb/pom.xml 370(+370 -0)
msa/tb/README.md 83(+83 -0)
msa/tb-node/docker/Dockerfile 28(+28 -0)
msa/tb-node/docker/start-tb-node.sh 71(+71 -0)
msa/tb-node/pom.xml 190(+190 -0)
msa/transport/coap/docker/Dockerfile 31(+31 -0)
msa/transport/coap/docker/logback.xml 50(+50 -0)
msa/transport/coap/pom.xml 190(+190 -0)
msa/transport/http/docker/Dockerfile 31(+31 -0)
msa/transport/http/docker/logback.xml 50(+50 -0)
msa/transport/http/pom.xml 190(+190 -0)
msa/transport/mqtt/docker/Dockerfile 31(+31 -0)
msa/transport/mqtt/docker/logback.xml 50(+50 -0)
msa/transport/mqtt/pom.xml 190(+190 -0)
msa/transport/pom.xml 55(+55 -0)
msa/web-ui/.gitignore 31(+31 -0)
msa/web-ui/build.gradle 125(+125 -0)
msa/web-ui/config/default.yml 30(+30 -0)
msa/web-ui/config/logger.js 59(+59 -0)
msa/web-ui/config/tb-web-ui.conf 15(+4 -11)
msa/web-ui/docker/Dockerfile 28(+28 -0)
msa/web-ui/docker/start-web-ui.sh 29(+29 -0)
msa/web-ui/install.js 42(+42 -0)
msa/web-ui/package.json 38(+38 -0)
msa/web-ui/pom.xml 423(+423 -0)
msa/web-ui/server.js 129(+129 -0)
msa/web-ui/src/main/assembly/windows.xml 75(+75 -0)
msa/web-ui/src/main/scripts/init/tb-web-ui 233(+233 -0)
netty-mqtt/pom.xml 4(+2 -2)
pom.xml 60(+35 -25)
rule-engine/pom.xml 2(+1 -1)
rule-engine/rule-engine-api/pom.xml 2(+1 -1)
rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java 2(+2 -0)
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java 155(+155 -0)
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java 2(+1 -1)
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/filter/TbMsgTypeSwitchNode.java 6(+5 -1)
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java 6(+6 -0)
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java 2(+1 -1)
rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java 7(+3 -4)
tools/pom.xml 2(+1 -1)
transport/coap/build.gradle 140(+140 -0)
transport/coap/pom.xml 301(+279 -22)
transport/coap/src/main/assembly/windows.xml 71(+71 -0)
transport/coap/src/main/conf/logback.xml 43(+43 -0)
transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java 48(+48 -0)
transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java 265(+0 -265)
transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java 237(+0 -237)
transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java 135(+0 -135)
transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java 77(+0 -77)
transport/http/build.gradle 140(+140 -0)
transport/http/pom.xml 298(+279 -19)
transport/http/src/main/assembly/windows.xml 71(+71 -0)
transport/http/src/main/conf/logback.xml 43(+43 -0)
transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java 47(+47 -0)
transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java 235(+0 -235)
transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java 164(+0 -164)
transport/mqtt/build.gradle 140(+140 -0)
transport/mqtt/pom.xml 305(+279 -26)
transport/mqtt/src/main/assembly/windows.xml 71(+71 -0)
transport/mqtt/src/main/conf/logback.xml 43(+43 -0)
transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java 49(+49 -0)
transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java 273(+0 -273)
transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java 423(+0 -423)
transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java 115(+0 -115)
transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java 205(+0 -205)
transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java 273(+0 -273)
transport/pom.xml 20(+2 -18)
ui/.stylelintrc 6(+3 -3)
ui/package.json 24(+19 -5)
ui/pom.xml 2(+1 -1)
ui/src/app/api/datasource.service.js 18(+15 -3)
ui/src/app/api/entity.service.js 54(+52 -2)
ui/src/app/api/entity-view.service.js 211(+211 -0)
ui/src/app/api/subscription.js 16(+12 -4)
ui/src/app/api/time.service.js 53(+39 -14)
ui/src/app/api/user.service.js 3(+2 -1)
ui/src/app/app.js 34(+20 -14)
ui/src/app/common/thirdparty-fix.js 459(+459 -0)
ui/src/app/common/types.constant.js 17(+16 -1)
ui/src/app/components/dashboard.scss 4(+2 -2)
ui/src/app/components/grid.scss 4(+2 -2)
ui/src/app/components/menu-link.scss 6(+3 -3)
ui/src/app/components/side-menu.scss 2(+1 -1)
ui/src/app/dashboard/dashboard.scss 10(+5 -5)
ui/src/app/entity/entity-filter.tpl.html 83(+83 -0)
ui/src/app/entity-view/entity-view.controller.js 483(+483 -0)
ui/src/app/entity-view/entity-view.directive.js 151(+151 -0)
ui/src/app/entity-view/entity-view.routes.js 72(+72 -0)
ui/src/app/entity-view/entity-view.scss 47(+25 -22)
ui/src/app/entity-view/entity-view-fieldset.tpl.html 212(+212 -0)
ui/src/app/entity-view/entity-views.tpl.html 75(+75 -0)
ui/src/app/entity-view/index.js 41(+41 -0)
ui/src/app/layout/home.scss 2(+1 -1)
ui/src/app/layout/index.js 4(+2 -2)
ui/src/app/locale/locale.constant-en_US.json 122(+118 -4)
ui/src/app/locale/locale.constant-es_ES.json 2107(+1173 -934)
ui/src/app/locale/locale.constant-fr_FR.json 2919(+1460 -1459)
ui/src/app/locale/locale.constant-it_IT.json 89(+45 -44)
ui/src/app/locale/locale.constant-tr_TR.json 1545(+1545 -0)
ui/src/app/rulechain/rulechain.scss 16(+1 -15)
ui/src/app/services/menu.service.js 52(+42 -10)
ui/src/app/user/user.controller.js 2(+1 -1)
ui/src/app/widget/lib/alarms-table-widget.js 192(+177 -15)
ui/src/app/widget/lib/display-columns-panel.scss 24(+13 -11)
ui/src/app/widget/lib/entities-table-widget.js 95(+83 -12)
ui/src/app/widget/lib/flot-widget.js 458(+245 -213)
ui/src/app/widget/lib/google-map.js 2(+1 -1)
ui/src/app/widget/lib/rpc/knob.scss 2(+0 -2)
ui/src/app/widget/lib/rpc/round-switch.scss 95(+41 -54)
ui/src/app/widget/widget-editor.scss 2(+1 -1)
ui/src/scss/animations.scss 16(+8 -8)
ui/src/scss/main.scss 28(+11 -17)
ui/src/scss/mixins.scss 2(+2 -0)
Details
.gitignore 1(+1 -0)
diff --git a/.gitignore b/.gitignore
index 9039e8e..da8569b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,3 +32,4 @@ pom.xml.versionsBackup
**/Californium.properties
**/.env
.instance_id
+rebuild-docker.sh
.travis.yml 1(+1 -0)
diff --git a/.travis.yml b/.travis.yml
index d3591cf..e8d8847 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
application/pom.xml 46(+36 -10)
diff --git a/application/pom.xml b/application/pom.xml
index 3d7cbe1..d99f607 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>application</artifactId>
@@ -57,26 +57,30 @@
<artifactId>rule-engine-components</artifactId>
</dependency>
<dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>transport</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
</dependency>
<dependency>
- <groupId>org.thingsboard.transport</groupId>
- <artifactId>http</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>mqtt</artifactId>
</dependency>
<dependency>
- <groupId>org.thingsboard.transport</groupId>
- <artifactId>coap</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>http</artifactId>
</dependency>
<dependency>
- <groupId>org.thingsboard.transport</groupId>
- <artifactId>mqtt</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>coap</artifactId>
</dependency>
<dependency>
<groupId>org.thingsboard</groupId>
<artifactId>dao</artifactId>
</dependency>
<dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
+ </dependency>
+ <dependency>
<groupId>org.thingsboard</groupId>
<artifactId>dao</artifactId>
<type>test-jar</type>
@@ -538,7 +542,8 @@
<args>
<arg>-PprojectBuildDir=${project.build.directory}</arg>
<arg>-PprojectVersion=${project.version}</arg>
- <arg>-PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</arg>
+ <arg>-PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging}
+ </arg>
<arg>-PpkgName=${pkg.name}</arg>
<arg>-PpkgInstallFolder=${pkg.installFolder}</arg>
<arg>-PpkgLogFolder=${pkg.unixLogFolder}</arg>
@@ -573,6 +578,27 @@
</executions>
</plugin>
<plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-install-plugin</artifactId>
+ <configuration>
+ <file>${project.build.directory}/${pkg.name}.deb</file>
+ <artifactId>${project.artifactId}</artifactId>
+ <groupId>${project.groupId}</groupId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <packaging>deb</packaging>
+ </configuration>
+ <executions>
+ <execution>
+ <id>install-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>install-file</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
</plugin>
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 803f1d3..6550f58 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 \"<div id='\" + datasourceId +\n \"' class='tbDatasource-container'></div>\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"<div class='tbDatasource-title'>\" +\n tbDatasource.name + \"</div>\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"<table id='\" + tableId +\n \"' class='tbDatasource-table'><col width='30%'><col width='70%'></table>\"\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(\"<tr><td id='\" + labelCellId + \"'>\" + dataKey.label +\n \"</td><td id='\" + cellId +\n \"'></td></tr>\");\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 \"<div id='\" + datasourceId +\n \"' class='tbDatasource-container'></div>\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"<div class='tbDatasource-title'>\" +\n tbDatasource.name + \"</div>\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"<table id='\" + tableId +\n \"' class='tbDatasource-table'><col width='30%'><col width='70%'></table>\"\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(\"<tr><td id='\" + labelCellId + \"'>\" + dataKey.label +\n \"</td><td id='\" + cellId +\n \"'></td></tr>\");\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\"}"
@@ -27,7 +27,7 @@
"descriptor": {
"type": "latest",
"sizeX": 7.5,
- "sizeY": 4.5,
+ "sizeY": 6.5,
"resources": [],
"templateHtml": "<tb-entities-table-widget \n table-id=\"tableId\"\n ctx=\"ctx\">\n</tb-entities-table-widget>",
"templateCss": "",
@@ -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 \"<div id='\" + datasourceId +\n \"' class='tbDatasource-container'></div>\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"<table id='\" + tableId +\n \"' class='tbDatasource-table'><col width='30%'><col width='70%'></table>\"\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 \"<tr><td class='tbDatasource-data-key' id='\" + labelCellId +\"'>\" +\n dataKey.label +\n \"</td><td class='tbDatasource-value' id='\" +\n cellId +\n \"'></td></tr>\");\n } else {\n table.append(\n \"<tr style='vertical-align: bottom;'><td class='tbDatasource-data-key' id='\" + labelCellId +\"'>\" +\n dataKey.label +\n \"</td></tr><tr><td class='tbDatasource-value' id='\" +\n cellId +\n \"'></td></tr>\");\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 = '<span>' + html_org + '</span>';\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 \"<div id='\" + datasourceId +\n \"' class='tbDatasource-container'></div>\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"<table id='\" + tableId +\n \"' class='tbDatasource-table'><col width='30%'><col width='70%'></table>\"\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 \"<tr><td class='tbDatasource-data-key' id='\" + labelCellId +\"'>\" +\n dataKey.label +\n \"</td><td class='tbDatasource-value' id='\" +\n cellId +\n \"'></td></tr>\");\n } else {\n table.append(\n \"<tr style='vertical-align: bottom;'><td class='tbDatasource-data-key' id='\" + labelCellId +\"'>\" +\n dataKey.label +\n \"</td></tr><tr><td class='tbDatasource-value' id='\" +\n cellId +\n \"'></td></tr>\");\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 = '<span>' + html_org + '</span>';\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/application/src/main/data/json/system/widget_bundles/charts.json b/application/src/main/data/json/system/widget_bundles/charts.json
index a264ba6..71e9fa7 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/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 0000000..36ac8e4
--- /dev/null
+++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql
@@ -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.
+--
+
+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_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)
+);
+
+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
+ 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 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_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_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/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 0000000..bd2c341
--- /dev/null
+++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql
@@ -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.
+--
+
+DROP TABLE IF EXISTS entity_views;
+
+CREATE TABLE IF NOT EXISTS entity_views (
+ 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),
+ start_ts bigint,
+ end_ts bigint,
+ search_text varchar(255),
+ additional_info varchar
+);
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 0000000..c02571a
--- /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 0000000..14b717c
--- /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/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 0000000..1832b79
--- /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/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java
index d33734e..091b504 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;
@@ -48,6 +49,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;
@@ -62,12 +64,13 @@ 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.JsSandboxService;
+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;
import javax.annotation.Nullable;
import java.io.IOException;
@@ -161,6 +164,10 @@ public class ActorSystemContext {
@Autowired
@Getter
+ private EntityViewService entityViewService;
+
+ @Autowired
+ @Getter
private TelemetrySubscriptionService tsSubService;
@Autowired
@@ -169,7 +176,7 @@ public class ActorSystemContext {
@Autowired
@Getter
- private JsSandboxService jsSandbox;
+ private JsInvokeService jsSandbox;
@Autowired
@Getter
@@ -193,11 +200,16 @@ public class ActorSystemContext {
@Autowired
@Getter
- private MsgQueueService msgQueueService;
+ private DeviceStateService deviceStateService;
+
+ @Autowired
+ @Getter
+ private DeviceSessionCacheService deviceSessionCacheService;
+ @Lazy
@Autowired
@Getter
- private DeviceStateService deviceStateService;
+ private RuleEngineTransportService ruleEngineTransportService;
@Value("${cluster.partition_id}")
@Getter
@@ -247,17 +259,21 @@ public class ActorSystemContext {
@Getter
private boolean allowSystemMailService;
+ @Value("${transport.sessions.inactivity_timeout}")
@Getter
- @Setter
- private ActorSystem actorSystem;
+ private long sessionInactivityTimeout;
+
+ @Value("${transport.sessions.report_timeout}")
+ @Getter
+ private long sessionReportTimeout;
@Getter
@Setter
- private ActorRef appActor;
+ private ActorSystem actorSystem;
@Getter
@Setter
- private ActorRef sessionManagerActor;
+ private ActorRef appActor;
@Getter
@Setter
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 6a78f78..714ab57 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,21 +25,24 @@ 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;
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;
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;
@@ -52,16 +55,14 @@ import java.util.Optional;
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<TenantId, ActorRef> tenantActors;
+ private final BiMap<TenantId, ActorRef> tenantActors;
private AppActor(ActorSystemContext systemContext) {
super(systemContext, new SystemRuleChainManager(systemContext));
this.tenantService = systemContext.getTenantService();
- this.tenantActors = new HashMap<>();
+ this.tenantActors = HashBiMap.create();
}
@Override
@@ -71,22 +72,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<Tenant> 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);
}
}
@@ -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:
@@ -114,19 +113,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<ServerAddress> address = systemContext.getRoutingService().resolveById(msg.getEntityId());
if (address.isPresent()) {
@@ -139,7 +131,8 @@ public class AppActor extends RuleChainManagerActor {
private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) {
if (SYSTEM_TENANT.equals(msg.getTenantId())) {
- //TODO: ashvayka handle this.
+// 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());
}
@@ -152,16 +145,26 @@ 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());
} else {
- logger.debug("Invalid component lifecycle msg: {}", msg);
+ log.debug("[{}] Invalid component lifecycle msg: {}", msg.getTenantId(), msg);
}
}
@@ -169,25 +172,25 @@ 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()));
+ 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 {
throw new IllegalStateException("Remote actors are not supported!");
}
@@ -201,20 +204,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<Throwable, Directive>() {
- @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 99d0045..f53a410 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,43 +15,44 @@
*/
package org.thingsboard.server.actors.device;
-import akka.event.Logging;
-import akka.event.LoggingAdapter;
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.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 {
- 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() {
+ log.debug("[{}][{}] Starting device actor.", processor.tenantId, processor.deviceId);
+ try {
+ processor.initSessionTimeout(context());
+ log.debug("[{}][{}] Device actor started.", processor.tenantId, processor.deviceId);
+ } catch (Exception e) {
+ log.warn("[{}][{}] Unknown failure", processor.tenantId, processor.deviceId, e);
+ }
}
@Override
protected boolean process(TbActorMsg msg) {
switch (msg.getMsgType()) {
- 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);
@@ -74,11 +75,8 @@ 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);
+ case SESSION_TIMEOUT_MSG:
+ processor.checkSessionsTimeout();
break;
default:
return false;
@@ -86,22 +84,4 @@ public class DeviceActor extends ContextAwareActor {
return true;
}
- public static class ActorCreator extends ContextBasedCreator<DeviceActor> {
- 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/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
index a2ea048..41d1ea0 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
@@ -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;
@@ -25,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;
@@ -33,7 +33,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;
@@ -43,37 +42,34 @@ 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.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.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;
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.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;
+import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper;
import javax.annotation.Nullable;
import java.util.ArrayList;
@@ -87,24 +83,22 @@ 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;
/**
* @author Andrew Shvayka
*/
-public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor {
-
- private final TenantId tenantId;
- private final DeviceId deviceId;
- private final Map<SessionId, SessionInfo> sessions;
- private final Map<SessionId, SessionInfo> attributeSubscriptions;
- private final Map<SessionId, SessionInfo> rpcSubscriptions;
+@Slf4j
+class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor {
+
+ final TenantId tenantId;
+ final DeviceId deviceId;
+ private final Map<UUID, SessionInfoMetaData> sessions;
+ private final Map<UUID, SessionInfo> attributeSubscriptions;
+ private final Map<UUID, SessionInfo> rpcSubscriptions;
private final Map<Integer, ToDeviceRpcRequestMetadata> toDeviceRpcPendingMap;
private final Map<Integer, ToServerRpcRequestMetadata> toServerRpcPendingMap;
- private final Map<UUID, PendingSessionMsgData> pendingMsgs;
private final Gson gson = new Gson();
private final JsonParser jsonParser = new JsonParser();
@@ -114,8 +108,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
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<>();
@@ -123,8 +117,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
this.rpcSubscriptions = new HashMap<>();
this.toDeviceRpcPendingMap = new HashMap<>();
this.toServerRpcPendingMap = new HashMap<>();
- this.pendingMsgs = new HashMap<>();
initAttributes();
+ restoreSessions();
}
private void initAttributes() {
@@ -139,41 +133,36 @@ 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) {
- 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<SessionId> 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());
+ Set<UUID> syncSessionSet = new HashSet<>();
+ 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());
- systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(msg.getMsg().getId(), msg.getServerAddress(), null, null));
+ 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());
}
-
}
private void registerPendingRpcRequest(ActorContext context, ToDeviceRpcRequestActorMsg msg, boolean sent, ToDeviceRpcRequestMsg rpcRequest, long timeout) {
@@ -185,101 +174,82 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
void processServerSideRpcTimeout(ActorContext context, DeviceActorServerSideRpcTimeoutMsg msg) {
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));
- }
- }
-
- 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()) {
- 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());
- }
+ 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));
}
}
- private void sendPendingRequests(ActorContext context, SessionId sessionId, SessionType type, Optional<ServerAddress> 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) {
- logger.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId);
+ log.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId);
+ if (sessionType == TransportProtos.SessionType.SYNC) {
+ 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<Integer> 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<Map.Entry<Integer, ToDeviceRpcRequestMetadata>> processPendingRpc(ActorContext context, SessionId sessionId, Optional<ServerAddress> server, Set<Integer> sentOneWayIds) {
+ private Consumer<Map.Entry<Integer, ToDeviceRpcRequestMetadata>> processPendingRpc(ActorContext context, UUID sessionId, String nodeId, Set<Integer> sentOneWayIds) {
return entry -> {
- ToDeviceRpcRequestActorMsg requestActorMsg = entry.getValue().getMsg();
ToDeviceRpcRequest request = entry.getValue().getMsg().getMsg();
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 = 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, DeviceToDeviceActorMsg msg) {
- processSubscriptionCommands(context, msg);
- processRpcResponses(context, msg);
- processSessionStateMsgs(msg);
-
- 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;
- }
+ void process(ActorContext context, TransportToDeviceActorMsgWrapper wrapper) {
+ TransportToDeviceActorMsg msg = wrapper.getMsg();
+ 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());
+ reportLogicalDeviceActivity();
+ }
+ if (msg.hasPostTelemetry()) {
+ handlePostTelemetryRequest(context, msg.getSessionInfo(), msg.getPostTelemetry());
+ reportLogicalDeviceActivity();
+ }
+ if (msg.hasGetAttributes()) {
+ handleGetAttributesRequest(context, msg.getSessionInfo(), msg.getGetAttributes());
+ }
+ if (msg.hasToDeviceRPCCallResponse()) {
+ processRpcResponses(context, msg.getSessionInfo(), msg.getToDeviceRPCCallResponse());
+ }
+ if (msg.hasToServerRPCCallRequest()) {
+ handleClientSideRPCRequest(context, msg.getSessionInfo(), msg.getToServerRPCCallRequest());
+ reportLogicalDeviceActivity();
+ }
+ if (msg.hasSubscriptionInfo()) {
+ handleSessionActivity(context, msg.getSessionInfo(), msg.getSubscriptionInfo());
}
}
- private void reportActivity() {
+ private void reportLogicalDeviceActivity() {
systemContext.getDeviceStateService().onDeviceActivity(deviceId);
}
@@ -291,27 +261,27 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
systemContext.getDeviceStateService().onDeviceDisconnect(deviceId);
}
- private void handleGetAttributesRequest(DeviceToDeviceActorMsg src) {
- GetAttributesRequest request = (GetAttributesRequest) src.getPayload();
- ListenableFuture<List<AttributeKvEntry>> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, request.getClientAttributeNames());
- ListenableFuture<List<AttributeKvEntry>> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, request.getSharedAttributeNames());
-
+ private void handleGetAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, GetAttributeRequestMsg request) {
+ ListenableFuture<List<AttributeKvEntry>> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, toOptionalSet(request.getClientAttributeNamesList()));
+ ListenableFuture<List<AttributeKvEntry>> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, toOptionalSet(request.getSharedAttributeNamesList()));
+ int requestId = request.getRequestId();
Futures.addCallback(Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)), new FutureCallback<List<List<AttributeKvEntry>>>() {
@Override
public void onSuccess(@Nullable List<List<AttributeKvEntry>> 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, 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, sessionInfo);
}
});
}
@@ -328,220 +298,221 @@ 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<Long, List<KvEntry>> tsData = request.getData();
-
- PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(),
- SessionMsgType.POST_TELEMETRY_REQUEST, request.getRequestId(), true, tsData.size());
-
- for (Map.Entry<Long, List<KvEntry>> 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);
}
}
- private void handleClientSideRPCRequest(ActorContext context, DeviceToDeviceActorMsg src) {
- ToServerRpcRequestMsg request = (ToServerRpcRequestMsg) src.getPayload();
-
+ private void handleClientSideRPCRequest(ActorContext context, SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg request) {
+ UUID sessionId = getSessionId(sessionInfo);
JsonObject json = new JsonObject();
- json.addProperty("method", request.getMethod());
+ 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);
- PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), SessionMsgType.TO_SERVER_RPC_REQUEST, request.getRequestId(), false, 1);
- pushToRuleEngineWithTimeout(context, tbMsg, msgData);
+ 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(src.getSessionId(), src.getSessionType(), src.getServerAddress()));
+ toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(sessionId, getSessionType(sessionId), sessionInfo.getNodeId()));
+ }
+
+ private TransportProtos.SessionType getSessionType(UUID sessionId) {
+ return sessions.containsKey(sessionId) ? TransportProtos.SessionType.ASYNC : TransportProtos.SessionType.SYNC;
}
- public void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) {
+ 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());
+ 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());
}
}
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());
+ 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);
}
}
- 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());
}
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<AttributeKey> sharedKeys = msg.getDeletedKeys().stream()
+ List<String> 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<AttributeKvEntry> attributes = new ArrayList<>(msg.getValues());
if (attributes.size() > 0) {
- notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromShared(attributes));
+ List<TsKvProto> 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);
+ log.debug("[{}] No public server side attributes changed!", deviceId);
}
}
}
- if (notification != null) {
- ToDeviceMsg finalNotification = notification;
+ if (hasNotificationData) {
+ AttributeUpdateNotificationMsg finalNotification = notification.build();
attributeSubscriptions.entrySet().forEach(sub -> {
- ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(finalNotification, sub.getKey());
- sendMsgToSessionActor(response, sub.getValue().getServer());
+ sendToTransport(finalNotification, sub.getKey(), sub.getValue().getNodeId());
});
}
} else {
- logger.debug("[{}] No registered attributes subscriptions to process!", deviceId);
+ log.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, SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg responseMsg) {
+ UUID sessionId = getSessionId(sessionInfo);
+ 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 {
+ log.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId());
}
}
- void processClusterEventMsg(ClusterEventMsg msg) {
- if (!msg.isAdded()) {
- logger.debug("[{}] Clearing attributes/rpc subscription for server [{}]", deviceId, msg.getServerAddress());
- Predicate<Map.Entry<SessionId, SessionInfo>> 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()) {
+ log.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId);
+ attributeSubscriptions.remove(sessionId);
+ } else {
+ SessionInfoMetaData sessionMD = sessions.get(sessionId);
+ if (sessionMD == null) {
+ sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId()));
+ }
+ sessionMD.setSubscribedToAttributes(true);
+ log.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId);
+ attributeSubscriptions.put(sessionId, sessionMD.getSessionInfo());
+ dumpSessions();
}
}
- 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);
+ private UUID getSessionId(SessionInfoProto sessionInfo) {
+ return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB());
+ }
+
+ private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToRPCMsg subscribeCmd) {
+ UUID sessionId = getSessionId(sessionInfo);
+ if (subscribeCmd.getUnsubscribe()) {
+ log.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId);
rpcSubscriptions.remove(sessionId);
+ } else {
+ SessionInfoMetaData sessionMD = sessions.get(sessionId);
+ if (sessionMD == null) {
+ sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId()));
+ }
+ sessionMD.setSubscribedToRPC(true);
+ log.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId);
+ rpcSubscriptions.put(sessionId, sessionMD.getSessionInfo());
+ sendPendingRequests(context, sessionId, sessionInfo);
+ dumpSessions();
}
}
- private void processSessionStateMsgs(DeviceToDeviceActorMsg msg) {
- SessionId sessionId = msg.getSessionId();
- FromDeviceMsg inMsg = msg.getPayload();
- if (inMsg instanceof SessionOpenMsg) {
- logger.debug("[{}] Processing new session [{}]", deviceId, sessionId);
+ private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) {
+ UUID sessionId = getSessionId(sessionInfo);
+ if (msg.getEvent() == SessionEvent.OPEN) {
+ if (sessions.containsKey(sessionId)) {
+ log.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId);
+ return;
+ }
+ log.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));
+ notifyTransportAboutClosedSession(sessionIdToRemove, sessions.remove(sessionIdToRemove));
}
}
- sessions.put(sessionId, new SessionInfo(SessionType.ASYNC, msg.getServerAddress()));
+ sessions.put(sessionId, new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfo.getNodeId())));
if (sessions.size() == 1) {
reportSessionOpen();
}
- } else if (inMsg instanceof SessionCloseMsg) {
- logger.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId);
+ dumpSessions();
+ } else if (msg.getEvent() == SessionEvent.CLOSED) {
+ log.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId);
sessions.remove(sessionId);
attributeSubscriptions.remove(sessionId);
rpcSubscriptions.remove(sessionId);
if (sessions.isEmpty()) {
reportSessionClose();
}
+ dumpSessions();
}
}
- private void sendMsgToSessionActor(ActorSystemToDeviceSessionActorMsg response, Optional<ServerAddress> 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());
+ 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());
+ if (subscriptionInfo.getAttributeSubscription()) {
+ attributeSubscriptions.putIfAbsent(sessionId, sessionMD.getSessionInfo());
+ }
+ if (subscriptionInfo.getRpcSubscription()) {
+ rpcSubscriptions.putIfAbsent(sessionId, sessionMD.getSessionInfo());
+ }
}
+ dumpSessions();
}
void processCredentialsUpdate() {
- sessions.forEach(this::closeSession);
+ sessions.forEach(this::notifyTransportAboutClosedSession);
attributeSubscriptions.clear();
rpcSubscriptions.clear();
+ dumpSessions();
}
- private void closeSession(SessionId sessionId, SessionInfo sessionInfo) {
- sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(new SessionCloseNotification(), sessionId), sessionInfo.getServer());
+ private void notifyTransportAboutClosedSession(UUID sessionId, SessionInfoMetaData sessionMd) {
+ DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder()
+ .setSessionIdMSB(sessionId.getMostSignificantBits())
+ .setSessionIdLSB(sessionId.getLeastSignificantBits())
+ .setSessionCloseNotification(SessionCloseNotificationProto.getDefaultInstance()).build();
+ systemContext.getRuleEngineTransportService().process(sessionMd.getSessionInfo().getNodeId(), msg);
}
void processNameOrTypeUpdate(DeviceNameOrTypeUpdateMsg msg) {
@@ -552,4 +523,178 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
this.defaultMetaData.putValue("deviceType", deviceType);
}
+ private JsonObject getJsonObject(List<KeyValueProto> 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;
+ }
+
+ private Optional<Set<String>> toOptionalSet(List<String> strings) {
+ if (strings == null || strings.isEmpty()) {
+ return Optional.empty();
+ } else {
+ return Optional.of(new HashSet<>(strings));
+ }
+ }
+
+ 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, String nodeId) {
+ DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder()
+ .setSessionIdMSB(sessionId.getMostSignificantBits())
+ .setSessionIdLSB(sessionId.getLeastSignificantBits())
+ .setAttributeUpdateNotification(notificationMsg).build();
+ 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<TsKvProto> toTsKvProtos(@Nullable List<AttributeKvEntry> result) {
+ List<TsKvProto> 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();
+ }
+
+ private void restoreSessions() {
+ log.debug("[{}] Restoring sessions from cache", deviceId);
+ TransportProtos.DeviceSessionsCacheEntry sessionsDump = systemContext.getDeviceSessionCacheService().get(deviceId);
+ if (sessionsDump.getSerializedSize() == 0) {
+ log.debug("[{}] No session information found", deviceId);
+ 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 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);
+ }
+ log.debug("[{}] Restored session: {}", deviceId, sessionMD);
+ }
+ log.debug("[{}] Restored sessions: {}, rpc subscriptions: {}, attribute subscriptions: {}", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size());
+ }
+
+ private void dumpSessions() {
+ log.debug("[{}] Dumping sessions: {}, rpc subscriptions: {}, attribute subscriptions: {} to cache", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size());
+ List<TransportProtos.SessionSubscriptionInfoProto> 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());
+ log.debug("[{}] Dumping session: {}", deviceId, sessionMD);
+ });
+ 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<UUID, SessionInfoMetaData> 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 04c457c..fe09077 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
@@ -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<ServerAddress> server;
+ private final String nodeId;
+ private long lastActivityTime;
}
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 f82a8c2..669d94b 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<ServerAddress> server;
+ private final UUID sessionId;
+ private final TransportProtos.SessionType type;
+ private final String nodeId;
}
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 14bb636..760d4a6 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;
@@ -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 9e38c17..9fa087e 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;
@@ -26,6 +27,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;
@@ -36,15 +38,13 @@ import java.util.*;
*/
public class RpcManagerActor extends ContextAwareActor {
- private final LoggingAdapter log = Logging.getLogger(getContext().system(), this);
-
private final Map<ServerAddress, SessionActorInfo> sessionActors;
private final Map<ServerAddress, Queue<ClusterAPIProtos.ClusterMessage>> pendingMsgs;
private final ServerAddress instance;
- public RpcManagerActor(ActorSystemContext systemContext) {
+ private RpcManagerActor(ActorSystemContext systemContext) {
super(systemContext);
this.sessionActors = new HashMap<>();
this.pendingMsgs = new HashMap<>();
@@ -54,7 +54,6 @@ public class RpcManagerActor extends ContextAwareActor {
.filter(otherServer -> otherServer.getServerAddress().compareTo(instance) > 0)
.forEach(otherServer -> onCreateSessionRequest(
new RpcSessionCreateRequestMsg(UUID.randomUUID(), otherServer.getServerAddress(), null)));
-
}
@Override
@@ -100,24 +99,23 @@ 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);
+ 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<ClusterAPIProtos.ClusterMessage> queue = pendingMsgs.get(address);
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);
}
} else {
- logger.warning("Cluster msg doesn't have set Server Address [{}]", msg);
+ log.warn("Cluster msg doesn't have server address [{}]", msg);
}
}
@@ -162,9 +160,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 +180,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<ClusterAPIProtos.ClusterMessage> 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());
@@ -208,7 +206,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/rpc/RpcSessionActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java
index c9cf869..86509ca 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/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java
index 7279347..0baecea 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,12 +36,15 @@ 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;
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;
@@ -154,7 +158,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
@@ -213,6 +217,11 @@ class DefaultTbContext implements TbContext {
}
@Override
+ public EntityViewService getEntityViewService() {
+ return mainCtx.getEntityViewService();
+ }
+
+ @Override
public MailService getMailService() {
if (mainCtx.isAllowSystemMailService()) {
return mainCtx.getMailService();
@@ -226,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<RuleEngineDeviceRpcResponse> 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/ruleChain/RuleChainActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java
index dbad7c0..be39320 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;
@@ -33,7 +34,7 @@ public class RuleChainActor extends ComponentActor<RuleChainId, RuleChainActorMe
private RuleChainActor(ActorSystemContext systemContext, TenantId tenantId, RuleChainId ruleChainId) {
super(systemContext, tenantId, ruleChainId);
setProcessor(new RuleChainActorMessageProcessor(tenantId, ruleChainId, systemContext,
- logger, context().parent(), context().self()));
+ context().parent(), context().self()));
}
@Override
@@ -79,7 +80,7 @@ public class RuleChainActor extends ComponentActor<RuleChainId, RuleChainActorMe
}
@Override
- public RuleChainActor create() throws Exception {
+ public RuleChainActor create() {
return new RuleChainActor(context, tenantId, ruleChainId);
}
}
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 fe02335..5c6c676 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
@@ -23,9 +23,9 @@ import com.datastax.driver.core.utils.UUIDs;
import java.util.Optional;
+import lombok.extern.slf4j.Slf4j;
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;
@@ -56,6 +56,7 @@ import java.util.stream.Collectors;
/**
* @author Andrew Shvayka
*/
+@Slf4j
public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleChainId> {
private static final long DEFAULT_CLUSTER_PARTITION = 0L;
@@ -68,59 +69,58 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
private RuleNodeId firstId;
private RuleNodeCtx firstNode;
private boolean started;
+ private String ruleChainName;
RuleChainActorMessageProcessor(TenantId tenantId, RuleChainId ruleChainId, ActorSystemContext systemContext
- , LoggingAdapter logger, ActorRef parent, ActorRef self) {
- super(systemContext, logger, tenantId, ruleChainId);
+ , ActorRef parent, ActorRef self) {
+ super(systemContext, tenantId, ruleChainId);
this.parent = parent;
this.self = self;
this.nodeActors = new HashMap<>();
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<RuleNode> 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 [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode);
ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode);
nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode));
}
initRoutes(ruleChain, ruleNodeList);
- reprocess(ruleNodeList);
started = true;
} else {
onUpdate(context);
}
}
- private void reprocess(List<RuleNode> 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 {
+ public void onUpdate(ActorContext context) {
RuleChain ruleChain = service.findRuleChainById(entityId);
+ ruleChainName = ruleChain.getName();
List<RuleNode> 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 [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode);
ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode);
nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode));
} else {
+ log.trace("[{}][{}] Updating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode);
existing.setSelf(ruleNode);
existing.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, existing.getSelf().getId(), ComponentLifecycleEvent.UPDATED), self);
}
@@ -129,16 +129,17 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
Set<RuleNodeId> existingNodes = ruleNodeList.stream().map(RuleNode::getId).collect(Collectors.toSet());
List<RuleNodeId> 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);
});
initRoutes(ruleChain, ruleNodeList);
- reprocess(ruleNodeList);
}
@Override
- public void stop(ActorContext context) throws Exception {
+ public void stop(ActorContext context) {
+ log.trace("[{}][{}] Stopping rule chain with {} nodes", tenantId, entityId, nodeActors.size());
nodeActors.values().stream().map(RuleNodeCtx::getSelfActor).forEach(context::stop);
nodeActors.clear();
nodeRoutes.clear();
@@ -147,7 +148,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
}
@Override
- public void onClusterEventMsg(ClusterEventMsg msg) throws Exception {
+ public void onClusterEventMsg(ClusterEventMsg msg) {
}
@@ -164,10 +165,12 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
// Populating the routes map;
for (RuleNode ruleNode : ruleNodeList) {
List<EntityRelation> 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) {
@@ -181,24 +184,23 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
}
firstId = ruleChain.getFirstRuleNodeId();
- firstNode = nodeActors.get(ruleChain.getFirstRuleNodeId());
+ firstNode = nodeActors.get(firstId);
state = ComponentLifecycleState.ACTIVE;
}
void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg envelope) {
+ log.trace("[{}][{}] Processing message [{}]: {}", entityId, firstId, envelope.getTbMsg().getId(), envelope.getTbMsg());
checkActive();
if (firstNode != null) {
- putToQueue(enrichWithRuleChainId(envelope.getTbMsg()), msg -> pushMsgToNode(firstNode, msg, ""));
+ log.trace("[{}][{}] Pushing message to first rule node", entityId, firstId);
+ 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 +208,16 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
checkActive();
if (envelope.isEnqueue()) {
if (firstNode != null) {
- putToQueue(enrichWithRuleChainId(envelope.getMsg()), msg -> 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());
}
}
}
@@ -234,7 +237,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
private void onRemoteTellNext(ServerAddress serverAddress, RuleNodeToRuleChainTellNextMsg envelope) {
TbMsg msg = envelope.getMsg();
- logger.debug("Forwarding [{}] msg to remote server [{}] due to changed originator id: [{}]", msg.getId(), serverAddress, msg.getOriginator());
+ log.debug("Forwarding [{}] msg to remote server [{}] due to changed originator id: [{}]", msg.getId(), serverAddress, msg.getOriginator());
envelope = new RemoteToRuleChainTellNextMsg(envelope, tenantId, entityId);
systemContext.getRpcService().tell(systemContext.getEncodingService().convertToProtoDataMessage(serverAddress, envelope));
}
@@ -248,16 +251,20 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
int relationsCount = relations.size();
EntityId ackId = msg.getRuleNodeId() != null ? msg.getRuleNodeId() : msg.getRuleChainId();
if (relationsCount == 0) {
+ log.trace("[{}][{}][{}] No outbound relations to process", tenantId, entityId, msg.getId());
if (ackId != null) {
- queue.ack(tenantId, msg, ackId.getId(), msg.getClusterPartition());
+// TODO: Ack this message in Kafka
+// queue.ack(tenantId, msg, ackId.getId(), msg.getClusterPartition());
}
} else if (relationsCount == 1) {
for (RuleNodeRelation relation : relations) {
+ log.trace("[{}][{}][{}] Pushing message to single target: [{}]", tenantId, entityId, msg.getId(), relation.getOut());
pushToTarget(msg, relation.getOut(), relation.getType());
}
} else {
for (RuleNodeRelation relation : relations) {
EntityId target = relation.getOut();
+ log.trace("[{}][{}][{}] Pushing message to multiple targets: [{}]", tenantId, entityId, msg.getId(), relation.getOut());
switch (target.getEntityType()) {
case RULE_NODE:
enqueueAndForwardMsgCopyToNode(msg, target, relation.getType());
@@ -269,7 +276,8 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
}
//TODO: Ideally this should happen in async way when all targets confirm that the copied messages are successfully written to corresponding target queues.
if (ackId != null) {
- queue.ack(tenantId, msg, ackId.getId(), msg.getClusterPartition());
+// TODO: Ack this message in Kafka
+// queue.ack(tenantId, msg, ackId.getId(), msg.getClusterPartition());
}
}
}
@@ -296,7 +304,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor<RuleCh
RuleNodeId targetId = new RuleNodeId(target.getId());
RuleNodeCtx targetNodeCtx = nodeActors.get(targetId);
TbMsg copy = msg.copy(UUIDs.timeBased(), entityId, targetId, DEFAULT_CLUSTER_PARTITION);
- putToQueue(copy, queuedMsg -> 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/ruleChain/RuleNodeActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java
index 273a569..f5521a0 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java
@@ -32,7 +32,7 @@ public class RuleNodeActor extends ComponentActor<RuleNodeId, RuleNodeActorMessa
super(systemContext, tenantId, ruleNodeId);
this.ruleChainId = ruleChainId;
setProcessor(new RuleNodeActorMessageProcessor(tenantId, ruleChainId, ruleNodeId, systemContext,
- logger, context().parent(), context().self()));
+ context().parent(), context().self()));
}
@Override
@@ -60,7 +60,9 @@ public class RuleNodeActor extends ComponentActor<RuleNodeId, RuleNodeActorMessa
}
private void onRuleNodeToSelfMsg(RuleNodeToSelfMsg msg) {
- logger.debug("[{}] Going to process rule msg: {}", id, msg.getMsg());
+ if (log.isDebugEnabled()) {
+ log.debug("[{}][{}][{}] Going to process rule msg: {}", ruleChainId, id, processor.getComponentName(), msg.getMsg());
+ }
try {
processor.onRuleToSelfMsg(msg);
increaseMessagesProcessedCount();
@@ -70,7 +72,9 @@ public class RuleNodeActor extends ComponentActor<RuleNodeId, RuleNodeActorMessa
}
private void onRuleChainToRuleNodeMsg(RuleChainToRuleNodeMsg msg) {
- logger.debug("[{}] Going to process rule msg: {}", id, msg.getMsg());
+ if (log.isDebugEnabled()) {
+ log.debug("[{}][{}][{}] Going to process rule msg: {}", ruleChainId, id, processor.getComponentName(), msg.getMsg());
+ }
try {
processor.onRuleChainToRuleNodeMsg(msg);
increaseMessagesProcessedCount();
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java
index acb171d..a4bd1d0 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java
@@ -44,8 +44,8 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor<RuleNod
private TbContext defaultCtx;
RuleNodeActorMessageProcessor(TenantId tenantId, RuleChainId ruleChainId, RuleNodeId ruleNodeId, ActorSystemContext systemContext
- , LoggingAdapter logger, ActorRef parent, ActorRef self) {
- super(systemContext, logger, tenantId, ruleNodeId);
+ , ActorRef parent, ActorRef self) {
+ super(systemContext, tenantId, ruleNodeId);
this.parent = parent;
this.self = self;
this.service = systemContext.getRuleChainService();
@@ -75,7 +75,7 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor<RuleNod
}
@Override
- public void stop(ActorContext context) throws Exception {
+ public void stop(ActorContext context) {
if (tbNode != null) {
tbNode.destroy();
}
@@ -83,7 +83,7 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor<RuleNod
}
@Override
- public void onClusterEventMsg(ClusterEventMsg msg) throws Exception {
+ public void onClusterEventMsg(ClusterEventMsg msg) {
}
@@ -111,6 +111,11 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor<RuleNod
}
}
+ @Override
+ public String getComponentName() {
+ return ruleNode.getName();
+ }
+
private TbNode initComponent(RuleNode ruleNode) throws Exception {
Class<?> componentClazz = Class.forName(ruleNode.getType());
TbNode tbNode = (TbNode) (componentClazz.newInstance());
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 f7e80e4..08d1dd8 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/ComponentActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java
index ed59051..1f084e1 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
@@ -18,6 +18,7 @@ package org.thingsboard.server.actors.service;
import akka.actor.ActorRef;
import akka.event.Logging;
import akka.event.LoggingAdapter;
+import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.actors.shared.ComponentMsgProcessor;
import org.thingsboard.server.actors.stats.StatsPersistMsg;
@@ -32,8 +33,6 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg;
*/
public abstract class ComponentActor<T extends EntityId, P extends ComponentMsgProcessor<T>> extends ContextAwareActor {
- protected final LoggingAdapter logger = Logging.getLogger(getContext().system(), this);
-
private long lastPersistedErrorTs = 0L;
protected final TenantId tenantId;
protected final T id;
@@ -54,13 +53,14 @@ public abstract class ComponentActor<T extends EntityId, P extends ComponentMsgP
@Override
public void preStart() {
try {
+ log.debug("[{}][{}][{}] Starting processor.", tenantId, id, id.getEntityType());
processor.start(context());
logLifecycleEvent(ComponentLifecycleEvent.STARTED);
if (systemContext.isStatisticsEnabled()) {
scheduleStatsPersistTick();
}
} catch (Exception e) {
- logger.warning("[{}][{}] Failed to start {} processor: {}", tenantId, id, id.getEntityType(), e);
+ log.warn("[{}][{}] Failed to start {} processor: {}", tenantId, id, id.getEntityType(), e);
logAndPersist("OnStart", e, true);
logLifecycleEvent(ComponentLifecycleEvent.STARTED, e);
}
@@ -70,7 +70,7 @@ public abstract class ComponentActor<T extends EntityId, P extends ComponentMsgP
try {
processor.scheduleStatsPersistTick(context(), systemContext.getStatisticsPersistFrequency());
} catch (Exception e) {
- logger.error("[{}][{}] Failed to schedule statistics store message. No statistics is going to be stored: {}", tenantId, id, e.getMessage());
+ log.error("[{}][{}] Failed to schedule statistics store message. No statistics is going to be stored: {}", tenantId, id, e.getMessage());
logAndPersist("onScheduleStatsPersistMsg", e);
}
}
@@ -78,16 +78,18 @@ public abstract class ComponentActor<T extends EntityId, P extends ComponentMsgP
@Override
public void postStop() {
try {
+ log.debug("[{}][{}] Stopping processor.", tenantId, id, id.getEntityType());
processor.stop(context());
logLifecycleEvent(ComponentLifecycleEvent.STOPPED);
} catch (Exception e) {
- logger.warning("[{}][{}] Failed to stop {} processor: {}", tenantId, id, id.getEntityType(), e.getMessage());
+ log.warn("[{}][{}] Failed to stop {} processor: {}", tenantId, id, id.getEntityType(), e.getMessage());
logAndPersist("OnStop", e, true);
logLifecycleEvent(ComponentLifecycleEvent.STOPPED, e);
}
}
protected void onComponentLifecycleMsg(ComponentLifecycleMsg msg) {
+ log.debug("[{}][{}][{}] onComponentLifecycleMsg: [{}]", tenantId, id, id.getEntityType(), msg.getEvent());
try {
switch (msg.getEvent()) {
case CREATED:
@@ -148,9 +150,9 @@ public abstract class ComponentActor<T extends EntityId, P extends ComponentMsgP
private void logAndPersist(String method, Exception e, boolean critical) {
errorsOccurred++;
if (critical) {
- logger.warning("[{}][{}] Failed to process {} msg: {}", id, tenantId, method, e);
+ log.warn("[{}][{}][{}] Failed to process {} msg: {}", id, tenantId, processor.getComponentName(), method, e);
} else {
- logger.debug("[{}][{}] Failed to process {} msg: {}", id, tenantId, method, e);
+ log.debug("[{}][{}][{}] Failed to process {} msg: {}", id, tenantId, processor.getComponentName(), method, e);
}
long ts = System.currentTimeMillis();
if (ts - lastPersistedErrorTs > 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 3624127..1c7e2d2 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,17 @@
*/
package org.thingsboard.server.actors.service;
+import akka.actor.Terminated;
import akka.actor.UntypedActor;
-import akka.event.Logging;
-import akka.event.LoggingAdapter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.msg.TbActorMsg;
+
public abstract class ContextAwareActor extends UntypedActor {
- protected final LoggingAdapter logger = Logging.getLogger(getContext().system(), this);
+
+ protected final Logger log = LoggerFactory.getLogger(getClass());
public static final int ENTITY_PACK_LIMIT = 1024;
@@ -35,21 +38,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/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java
index fac5d97..85b8943 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;
@@ -38,13 +37,11 @@ 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;
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;
@@ -68,10 +65,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
@@ -90,8 +84,6 @@ public class DefaultActorService implements ActorService {
private ActorRef appActor;
- private ActorRef sessionManagerActor;
-
private ActorRef rpcManagerActor;
@PostConstruct
@@ -104,10 +96,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");
@@ -115,8 +103,6 @@ public class DefaultActorService implements ActorService {
actorContext.setStatsActor(statsActor);
rpcService.init(this);
-
- discoveryService.addListener(this);
log.info("Actor system initialized.");
}
@@ -137,12 +123,6 @@ public class DefaultActorService implements ActorService {
}
@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);
broadcast(new ClusterEventMsg(server.getServerAddress(), true));
@@ -178,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
@@ -196,16 +171,15 @@ 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());
}
@Override
public void onReceivedMsg(ServerAddress source, ClusterAPIProtos.ClusterMessage msg) {
- ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort());
- log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress);
+ ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort(), source.getServerType());
if (log.isDebugEnabled()) {
- log.info("MSG: ", msg);
+ log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress);
+ log.info("MSG: {}", msg);
}
switch (msg.getMessageType()) {
case CLUSTER_ACTOR_MESSAGE:
@@ -239,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/actors/shared/AbstractContextAwareMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java
index 8864486..b707baf 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,61 +22,49 @@ 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;
}
- 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) {
- logger.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs);
+ private void schedulePeriodicMsgWithDelay(Object msg, long delayInMs, long periodInMs, ActorRef target) {
+ 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);
}
-
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) {
- logger.debug("Scheduling msg {} with delay {} ms", msg, delayInMs);
+ private void scheduleMsgWithDelay(Object msg, long delayInMs, ActorRef target) {
+ log.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/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java
index 1cf8339..46a76e6 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;
@@ -26,25 +27,25 @@ 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;
+@Slf4j
public abstract class ComponentMsgProcessor<T extends EntityId> extends AbstractContextAwareMsgProcessor {
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);
+ protected ComponentMsgProcessor(ActorSystemContext systemContext, TenantId tenantId, T id) {
+ super(systemContext);
this.tenantId = tenantId;
this.entityId = id;
- this.queue = systemContext.getMsgQueueService();
}
+ public abstract String getComponentName();
+
public abstract void start(ActorContext context) throws Exception;
public abstract void stop(ActorContext context) throws Exception;
@@ -82,22 +83,9 @@ public abstract class ComponentMsgProcessor<T extends EntityId> extends Abstract
protected void checkActive() {
if (state != ComponentLifecycleState.ACTIVE) {
- throw new IllegalStateException("Rule chain is not active!");
+ 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);
}
}
- protected void putToQueue(final TbMsg tbMsg, final Consumer<TbMsg> onSuccess) {
- EntityId entityId = tbMsg.getRuleNodeId() != null ? tbMsg.getRuleNodeId() : tbMsg.getRuleChainId();
- Futures.addCallback(queue.put(this.tenantId, tbMsg, entityId.getId(), tbMsg.getClusterPartition()), new FutureCallback<Void>() {
- @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/actors/shared/EntityActorsManager.java b/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java
index 1b9e6a8..dd03d69 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<T extends EntityId, A extends UntypedActor, M extends SearchTextBased<? extends UUIDBased>> {
protected final ActorSystemContext systemContext;
- protected final Map<T, ActorRef> actors;
+ protected final BiMap<T, ActorRef> 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<T extends EntityId, A extends UntypedA
}
}
- public void visit(M entity, ActorRef actorRef) {}
+ public void visit(M entity, ActorRef actorRef) {
+ }
public ActorRef getOrCreateActor(ActorContext context, T entityId) {
return actors.computeIfAbsent(entityId, eId ->
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 11ed5a3..d349fec 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<RuleChainId,
@Override
public void visit(RuleChain entity, ActorRef actorRef) {
- if (entity.isRoot()) {
+ if (entity != null && entity.isRoot()) {
rootChain = entity;
rootChainActor = actorRef;
}
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 8623370..79aa6da 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 460b64c..0d693ee 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;
@@ -33,6 +37,7 @@ import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.RuleChainId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.rule.RuleChain;
import org.thingsboard.server.common.msg.TbActorMsg;
import org.thingsboard.server.common.msg.aware.DeviceAwareMsg;
@@ -47,15 +52,14 @@ import java.util.Map;
public class TenantActor extends RuleChainManagerActor {
private final TenantId tenantId;
- private final Map<DeviceId, ActorRef> deviceActors;
+ private final BiMap<DeviceId, ActorRef> 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,16 +67,21 @@ 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);
}
}
@Override
+ public void postStop() {
+ log.info("[{}] Stopping tenant actor.", tenantId);
+ }
+
+ @Override
protected boolean process(TbActorMsg msg) {
switch (msg.getMsgType()) {
case CLUSTER_EVENT_MSG:
@@ -87,7 +96,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:
@@ -105,29 +114,26 @@ 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) {
ruleChainManager.getOrCreateActor(context(), msg.getRuleChainId()).tell(msg, self());
}
-
private void onToDeviceActorMsg(DeviceAwareMsg msg) {
getOrCreateDeviceActor(msg.getDeviceId()).tell(msg, ActorRef.noSender());
}
@@ -142,13 +148,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<TenantActor> {
@@ -162,7 +190,7 @@ public class TenantActor extends RuleChainManagerActor {
}
@Override
- public TenantActor create() throws Exception {
+ public TenantActor create() {
return new TenantActor(context, tenantId);
}
}
@@ -170,8 +198,8 @@ public class TenantActor extends RuleChainManagerActor {
private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function<Throwable, SupervisorStrategy.Directive>() {
@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/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java
index 586e8c3..00885e3 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,
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 de73fe0..825929f 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;
@@ -48,14 +50,17 @@ 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;
+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;
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;
@@ -69,6 +74,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;
@@ -139,6 +145,20 @@ public abstract class BaseController {
@Autowired
protected DeviceStateService deviceStateService;
+ @Autowired
+ protected EntityViewService entityViewService;
+
+ @Autowired
+ protected TelemetrySubscriptionService tsSubService;
+
+ @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);
@@ -149,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);
}
@@ -247,7 +267,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 &&
@@ -255,9 +274,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);
}
@@ -313,6 +336,9 @@ public abstract class BaseController {
case USER:
checkUserId(new UserId(entityId.getId()));
return;
+ case ENTITY_VIEW:
+ checkEntityViewId(new EntityViewId(entityId.getId()));
+ return;
default:
throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType());
}
@@ -335,11 +361,26 @@ 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 {
+ 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());
+ checkCustomerId(entityView.getCustomerId());
+ }
+
Asset checkAssetId(AssetId assetId) throws ThingsboardException {
try {
validateId(assetId, "Incorrect assetId " + assetId);
@@ -354,9 +395,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 {
@@ -465,8 +504,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);
}
@@ -530,16 +568,16 @@ public abstract class BaseController {
}
protected <I extends EntityId> I emptyId(EntityType entityType) {
- return (I)EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID);
+ return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID);
}
protected <E extends HasName, I extends EntityId> 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 <E extends HasName, I extends EntityId> 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();
}
@@ -555,7 +593,7 @@ public abstract class BaseController {
}
private <E extends HasName, I extends EntityId> 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:
@@ -579,6 +617,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 {
@@ -628,7 +672,7 @@ public abstract class BaseController {
String scope = extractParameter(String.class, 0, additionalInfo);
List<String> 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);
}
@@ -637,7 +681,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/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
index 85227e7..0bb6a66 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,10 @@ public class DashboardController extends BaseController {
public static final String DASHBOARD_ID = "dashboardId";
+ @Value("${dashboard.max_datapoints_limit}")
+ private long maxDatapointsLimit;
+
+
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
@RequestMapping(value = "/dashboard/serverTime", method = RequestMethod.GET)
@ResponseBody
@@ -57,6 +63,13 @@ public class DashboardController extends BaseController {
}
@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
public DashboardInfo getDashboardInfoById(@PathVariable(DASHBOARD_ID) String strDashboardId) throws ThingsboardException {
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 0000000..2a9c073
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java
@@ -0,0 +1,336 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.controller;
+
+import com.google.common.util.concurrent.FutureCallback;
+import 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;
+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.DataConstants;
+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.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.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;
+
+/**
+ * Created by Victor Basanets on 8/28/2017.
+ */
+@RestController
+@RequestMapping("/api")
+@Slf4j
+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)
+ @ResponseBody
+ public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException {
+ checkParameter(ENTITY_VIEW_ID, strEntityViewId);
+ try {
+ return checkEntityViewId(new EntityViewId(toUUID(strEntityViewId)));
+ } catch (Exception e) {
+ throw handleException(e);
+ }
+ }
+
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
+ @RequestMapping(value = "/entityView", method = RequestMethod.POST)
+ @ResponseBody
+ public EntityView saveEntityView(@RequestBody EntityView entityView) throws ThingsboardException {
+ try {
+ entityView.setTenantId(getCurrentUser().getTenantId());
+ EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView));
+ List<ListenableFuture<List<Void>>> 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<List<Void>> 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;
+ } catch (Exception e) {
+ logEntityAction(emptyId(EntityType.ENTITY_VIEW), entityView, null,
+ entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, e);
+ throw handleException(e);
+ }
+ }
+
+ private ListenableFuture<List<Void>> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection<String> keys, SecurityUser user) throws ThingsboardException {
+ EntityViewId entityId = entityView.getId();
+ if (keys != null && !keys.isEmpty()) {
+ ListenableFuture<List<AttributeKvEntry>> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys);
+ return Futures.transform(getAttrFuture, attributeKvEntries -> {
+ List<AttributeKvEntry> 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<Void>() {
+ @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);
+ }
+ }
+
+ @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<AttributeKvEntry> 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)
+ 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);
+ }
+ }
+
+ @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 {
+ checkParameter(CUSTOMER_ID, 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<EntityView> 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 {
+ checkParameter("customerId", strCustomerId);
+ try {
+ TenantId tenantId = getCurrentUser().getTenantId();
+ CustomerId customerId = new CustomerId(toUUID(strCustomerId));
+ checkCustomerId(customerId);
+ TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset);
+ 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);
+ }
+ }
+
+ @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+ @RequestMapping(value = "/tenant/entityViews", params = {"limit"}, method = RequestMethod.GET)
+ @ResponseBody
+ public TextPageData<EntityView> 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);
+
+ 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);
+ }
+ }
+
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
+ @RequestMapping(value = "/entityViews", method = RequestMethod.POST)
+ @ResponseBody
+ public List<EntityView> findByQuery(@RequestBody EntityViewSearchQuery query) throws ThingsboardException {
+ checkNotNull(query);
+ checkNotNull(query.getParameters());
+ checkNotNull(query.getEntityViewTypes());
+ checkEntityId(query.getParameters().getEntityId());
+ try {
+ List<EntityView> 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);
+ }
+ }
+
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
+ @RequestMapping(value = "/entityView/types", method = RequestMethod.GET)
+ @ResponseBody
+ public List<EntitySubtype> getEntityViewTypes() throws ThingsboardException {
+ try {
+ SecurityUser user = getCurrentUser();
+ TenantId tenantId = user.getTenantId();
+ ListenableFuture<List<EntitySubtype>> entityViewTypes = entityViewService.findEntityViewTypesByTenantId(tenantId);
+ return checkNotNull(entityViewTypes.get());
+ } catch (Exception e) {
+ throw handleException(e);
+ }
+ }
+}
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 86b8fda..fbd5faf 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, 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/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java
index 7062ca9..ef09a7a 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;
@@ -59,14 +61,11 @@ 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;
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;
@@ -94,15 +93,9 @@ import java.util.stream.Collectors;
public class TelemetryController extends BaseController {
@Autowired
- private AttributesService attributesService;
-
- @Autowired
private TimeseriesService tsService;
@Autowired
- private TelemetrySubscriptionService tsSubService;
-
- @Autowired
private AccessValidator accessValidator;
private ExecutorService executor;
@@ -257,6 +250,60 @@ public class TelemetryController extends BaseController {
}
@PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')")
+ @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE)
+ @ResponseBody
+ public DeferredResult<ResponseEntity> 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<ResponseEntity> deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys,
+ Long startTs, Long endTs, boolean rewriteLatestIfDeleted) throws ThingsboardException {
+ List<String> 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<DeleteTsKvQuery> deleteTsKvQueries = new ArrayList<>();
+ for (String key : keys) {
+ deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted));
+ }
+
+ ListenableFuture<List<Void>> future = tsService.remove(entityId, deleteTsKvQueries);
+ Futures.addCallback(future, new FutureCallback<List<Void>>() {
+ @Override
+ public void onSuccess(@Nullable List<Void> 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
public DeferredResult<ResponseEntity> deleteEntityAttributes(@PathVariable("deviceId") String deviceIdStr,
@@ -352,7 +399,7 @@ public class TelemetryController extends BaseController {
}
private DeferredResult<ResponseEntity> saveTelemetry(EntityId entityIdSrc, String requestBody, long ttl) throws ThingsboardException {
- TelemetryUploadRequest telemetryRequest;
+ Map<Long, List<KvEntry>> telemetryRequest;
JsonElement telemetryJson;
try {
telemetryJson = new JsonParser().parse(requestBody);
@@ -360,12 +407,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<TsKvEntry> entries = new ArrayList<>();
- for (Map.Entry<Long, List<KvEntry>> entry : telemetryRequest.getData().entrySet()) {
+ for (Map.Entry<Long, List<KvEntry>> entry : telemetryRequest.entrySet()) {
for (KvEntry kv : entry.getValue()) {
entries.add(new BasicTsKvEntry(entry.getKey(), kv));
}
@@ -513,6 +560,15 @@ public class TelemetryController extends BaseController {
};
}
+ private void logTimeseriesDeleted(SecurityUser user, EntityId entityId, List<String> 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<String> keys, Throwable e) {
try {
logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.ATTRIBUTES_DELETED, toException(e),
diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java
index 1a7c116..155f0a1 100644
--- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java
@@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException;
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.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.service.install.InstallScripts;
@@ -84,6 +85,8 @@ public class TenantController extends BaseController {
try {
TenantId tenantId = new TenantId(toUUID(strTenantId));
tenantService.deleteTenant(tenantId);
+
+ actorService.onEntityStateChange(tenantId, tenantId, ComponentLifecycleEvent.DELETED);
} catch (Exception e) {
throw handleException(e);
}
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 f863d0b..2486cd8 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;
@@ -88,6 +92,20 @@ 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");
+
+ case "2.1.1":
+ 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...");
systemDataLoaderService.deleteSystemWidgetBundle("charts");
@@ -114,9 +132,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/cluster/discovery/CurrentServerInstanceService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java
index 2232ef8..3ae7b16 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 516fca9..f9caafa 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<ServerInstance> 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 c21f1aa..358c847 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;
@@ -62,13 +63,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 6eee5f3..8f4525c 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<ServerInstance> {
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 c3ffbab..f5321e5 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,17 +16,22 @@
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;
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;
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;
+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;
@@ -35,7 +40,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,9 +51,10 @@ 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;
+import static org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent.Type.CHILD_REMOVED;
+
/**
* @author Andrew Shvayka
*/
@@ -79,12 +87,19 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
@Lazy
private DeviceStateService deviceStateService;
- private final List<DiscoveryServiceListener> listeners = new CopyOnWriteArrayList<>();
+ @Autowired
+ @Lazy
+ private ActorService actorService;
+
+ @Autowired
+ @Lazy
+ private ClusterRoutingService routingService;
private CuratorFramework client;
private PathChildrenCache cache;
private String nodePath;
+ private volatile boolean stopped = false;
@PostConstruct
public void init() {
@@ -106,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);
}
@@ -113,23 +129,74 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
@PreDestroy
public void destroy() {
+ stopped = true;
unpublishCurrentServer();
+ CloseableUtils.closeQuietly(cache);
CloseableUtils.closeQuietly(client);
log.info("Stopped discovery service");
}
@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);
+ 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) {
+ 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;
+ }
}
}
@@ -156,7 +223,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);
@@ -166,17 +233,15 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi
}
@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) {
+ 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())
@@ -185,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);
@@ -193,12 +266,16 @@ 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;
}
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 +284,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 272073d..bb76c74 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<ServerAddress> resolveById(EntityId entityId);
+ Optional<ServerAddress> resolveByUuid(ServerType server, UUID uuid);
+
+ Optional<ServerAddress> 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 5087b5c..f47f6d2 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<Long, ServerInstance> 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<ServerAddress> resolveById(EntityId entityId) {
- return resolveByUuid(entityId.getId());
+ return resolveByUuid(rootCircle, entityId.getId());
}
@Override
public Optional<ServerAddress> resolveByUuid(UUID uuid) {
+ return resolveByUuid(rootCircle, uuid);
+ }
+
+ @Override
+ public Optional<ServerAddress> resolveByUuid(ServerType server, UUID uuid) {
+ return resolveByUuid(circles[server.ordinal()], uuid);
+ }
+
+ @Override
+ public Optional<ServerAddress> resolveById(ServerType server, EntityId entityId) {
+ return resolveByUuid(circles[server.ordinal()], entityId.getId());
+ }
+
+ private Optional<ServerAddress> 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 0000000..a8c8f37
--- /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<Long, ServerInstance> 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<Long, ServerInstance> 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 7216c43..dd0a995 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,10 +15,13 @@
*/
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;
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;
@@ -33,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<ClusterAPIProtos.ClusterMessage> inputStream;
private StreamObserver<ClusterAPIProtos.ClusterMessage> outputStream;
@@ -40,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) {
@@ -53,6 +57,7 @@ public final class GrpcSession implements Closeable {
} else {
this.client = false;
}
+ this.channel = channel;
}
public void initInputStream() {
@@ -61,8 +66,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) {
@@ -104,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 f3ceed2..545baf8 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,32 @@ public class AnnotationComponentDiscoveryService implements ComponentDiscoverySe
private void registerRuleNodeComponents() {
Set<BeanDefinition> 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++;
+ try {
+ Thread.sleep(1000);
+ } catch (InterruptedException e1) {
+ throw new RuntimeException(e1);
+ }
+ }
+ }
+ 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/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java
index f4ff92c..a2aeefa 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
@@ -203,6 +212,47 @@ 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;
+
+ case "2.1.1":
+
+ log.info("Upgrading Cassandra DataBase from version {} to 2.1.2 ...", 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;
+ 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/cql/CassandraDbHelper.java b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java
index bdf980f..7c03a71 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()) {
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 2f9b4a5..699eaf1 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 3a4a837..b4a725d 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")
@@ -107,6 +117,46 @@ 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;
+ 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;
+ 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/rpc/DefaultDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java
index 64f5213..a65c1a5 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<FromDeviceRpcResponse> responseConsumer) {
- log.trace("[{}] Processing local rpc call to rule engine [{}]", request.getTenantId(), 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<FromDeviceRpcResponse> 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<FromDeviceRpcResponse> responseConsumer) {
- log.trace("[{}] Processing local rpc call to device [{}]", request.getTenantId(), request.getDeviceId());
- UUID requestId = request.getId();
- localToDeviceRpcRequests.put(requestId, responseConsumer);
- sendRpcRequestToDevice(request);
- scheduleTimeout(request, requestId, localToDeviceRpcRequests);
- }
-
- @Override
- public void processRpcResponseFromDevice(FromDeviceRpcResponse response) {
- log.trace("[{}] response to request: [{}]", this.hashCode(), response.getId());
- 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<FromDeviceRpcResponse> consumer = localToDeviceRpcRequests.remove(requestId);
+ Consumer<FromDeviceRpcResponse> 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<FromDeviceRpcResponse> 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<FromDeviceRpcResponse> 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()), routingService.getCurrentServer(),
- 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<FromDeviceRpcResponse> 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 4cee96e..bf12ec6 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<FromDeviceRpcResponse> responseConsumer);
- void processRestAPIRpcResponseFromRuleEngine(FromDeviceRpcResponse response);
+ void processResponseToServerSideRPCRequestFromRuleEngine(ServerAddress requestOriginAddress, FromDeviceRpcResponse response);
- void processRpcRequestToDevice(ToDeviceRpcRequest request, Consumer<FromDeviceRpcResponse> responseConsumer);
+ void forwardServerSideRPCRequestToDeviceActor(ToDeviceRpcRequest request, Consumer<FromDeviceRpcResponse> 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 9c3ce9a..75506df 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/rpc/ToServerRpcResponseActorMsg.java b/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java
index 201f656..d33a338 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/script/AbstractJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java
new file mode 100644
index 0000000..461b894
--- /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<UUID, String> scriptIdToNameMap = new ConcurrentHashMap<>();
+ protected Map<UUID, AtomicInteger> blackListedFunctions = new ConcurrentHashMap<>();
+
+ @Override
+ public ListenableFuture<UUID> 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<Object> 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<Void> 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<UUID> doEval(UUID scriptId, String functionName, String scriptBody);
+
+ protected abstract ListenableFuture<Object> 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/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java
new file mode 100644
index 0000000..edd7b35
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java
@@ -0,0 +1,211 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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.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.kafka.TbNodeIdProvider;
+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 = "remote", matchIfMissing = true)
+@Service
+public class RemoteJsInvokeService extends AbstractJsInvokeService {
+
+ @Autowired
+ private TbNodeIdProvider nodeIdProvider;
+
+ @Autowired
+ private TbKafkaSettings kafkaSettings;
+
+ @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_interval}")
+ private int responsePollDuration;
+
+ @Value("${js.remote.response_auto_commit_interval}")
+ private int autoCommitInterval;
+
+ @Getter
+ @Value("${js.remote.max_errors}")
+ private int maxErrors;
+
+ private TbKafkaRequestTemplate<JsInvokeProtos.RemoteJsRequest, JsInvokeProtos.RemoteJsResponse> kafkaTemplate;
+ private Map<UUID, String> scriptIdToBodysMap = new ConcurrentHashMap<>();
+
+ @PostConstruct
+ public void init() {
+ TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder<JsInvokeProtos.RemoteJsRequest> requestBuilder = TBKafkaProducerTemplate.builder();
+ 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<JsInvokeProtos.RemoteJsResponse> responseBuilder = TBKafkaConsumerTemplate.builder();
+ responseBuilder.settings(kafkaSettings);
+ responseBuilder.topic(responseTopicPrefix + "." + nodeIdProvider.getNodeId());
+ responseBuilder.clientId("js-" + nodeIdProvider.getNodeId());
+ responseBuilder.groupId("rule-engine-node-" + nodeIdProvider.getNodeId());
+ responseBuilder.autoCommit(true);
+ responseBuilder.autoCommitIntervalMs(autoCommitInterval);
+ responseBuilder.decoder(new RemoteJsResponseDecoder());
+ responseBuilder.requestIdExtractor((response) -> new UUID(response.getRequestIdMSB(), response.getRequestIdLSB()));
+
+ TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder
+ <JsInvokeProtos.RemoteJsRequest, JsInvokeProtos.RemoteJsResponse> builder = TbKafkaRequestTemplate.builder();
+ builder.requestTemplate(requestBuilder.build());
+ builder.responseTemplate(responseBuilder.build());
+ builder.maxPendingRequests(maxPendingRequests);
+ builder.maxRequestTimeout(maxRequestsTimeout);
+ builder.pollInterval(responsePollDuration);
+ kafkaTemplate = builder.build();
+ kafkaTemplate.init();
+ }
+
+ @PreDestroy
+ public void destroy() {
+ if (kafkaTemplate != null) {
+ kafkaTemplate.stop();
+ }
+ }
+
+ @Override
+ protected ListenableFuture<UUID> 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();
+
+ log.trace("Post compile request for scriptId [{}]", scriptId);
+ ListenableFuture<JsInvokeProtos.RemoteJsResponse> 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.getErrorDetails());
+ }
+ });
+ }
+
+ @Override
+ protected ListenableFuture<Object> 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.Builder jsRequestBuilder = JsInvokeProtos.JsInvokeRequest.newBuilder()
+ .setScriptIdMSB(scriptId.getMostSignificantBits())
+ .setScriptIdLSB(scriptId.getLeastSignificantBits())
+ .setFunctionName(functionName)
+ .setTimeout((int) maxRequestsTimeout)
+ .setScriptBody(scriptIdToBodysMap.get(scriptId));
+
+ for (int i = 0; i < args.length; i++) {
+ jsRequestBuilder.addArgs(args[i].toString());
+ }
+
+ JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder()
+ .setInvokeRequest(jsRequestBuilder.build())
+ .build();
+
+ ListenableFuture<JsInvokeProtos.RemoteJsResponse> 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.getErrorDetails());
+ }
+ });
+ }
+
+ @Override
+ 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<JsInvokeProtos.RemoteJsResponse> 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/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java
index 2ba87ec..2a98051 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;
@@ -36,16 +37,22 @@ 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, String script, String... argNames) {
+ public RuleNodeJsScriptEngine(JsInvokeService 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) {
- 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);
}
}
@@ -167,11 +174,13 @@ 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("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);
}
}
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 5cc9c55..ef2b5aa 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/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java
index 600820e..eaf1018 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,6 +26,7 @@ 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;
@@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.DeviceId;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.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;
@@ -44,6 +46,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 +69,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 +92,9 @@ public class AccessValidator {
@Autowired
protected RuleChainService ruleChainService;
+ @Autowired
+ protected EntityViewService entityViewService;
+
private ExecutorService executor;
@PostConstruct
@@ -158,6 +165,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 +303,27 @@ public class AccessValidator {
}
}
+ private void validateEntityView(final SecurityUser currentUser, EntityId entityId, FutureCallback<ValidationResult> callback) {
+ if (currentUser.isSystemAdmin()) {
+ callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION));
+ } else {
+ ListenableFuture<EntityView> 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 <T, V> FutureCallback<T> getCallback(FutureCallback<ValidationResult> callback, Function<T, ValidationResult<V>> transformer) {
return new FutureCallback<T>() {
@Override
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 544b6fd..68d9ada 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/java/org/thingsboard/server/service/security/ValidationCallback.java b/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java
index 81ab9b4..d4ac753 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<T> implements FutureCallback<ValidationResult> {
@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<T> implements FutureCallback<ValidationResult> {
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/session/DefaultDeviceSessionCacheService.java b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java
new file mode 100644
index 0000000..6201dab
--- /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/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java
index f4e37db..610cd3b 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/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java
index 548c417..7c77242 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
@@ -29,9 +29,11 @@ import org.thingsboard.rule.engine.api.util.DonAsynchron;
import org.thingsboard.server.actors.service.ActorService;
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.id.TenantId;
import org.thingsboard.server.common.data.kv.AttributeKvEntry;
import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry;
@@ -48,6 +50,8 @@ import org.thingsboard.server.common.data.kv.TsKvEntry;
import org.thingsboard.server.common.msg.cluster.SendToClusterMsg;
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;
@@ -64,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;
@@ -102,6 +107,9 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio
private ClusterRpcService rpcService;
@Autowired
+ private EntityViewService entityViewService;
+
+ @Autowired
@Lazy
private DeviceStateService stateService;
@@ -133,20 +141,41 @@ 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) && TelemetryFeature.TIMESERIES.equals(sub.getType())) {
+ EntityView entityView = entityViewService.findEntityViewById(new EntityViewId(entityId.getId()));
+ entityId = entityView.getEntityId();
+ startTime = entityView.getStartTimeMs();
+ endTime = entityView.getEndTimeMs();
+ sub = getUpdatedSubscriptionState(entityId, sub, entityView);
+ }
Optional<ServerAddress> 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);
- subscription = new Subscription(sub, true, address);
+ log.trace("[{}] Forwarding subscription [{}] for [{}] entity [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId, address);
+ subscription = new Subscription(sub, true, address, startTime, endTime);
tellNewSubscription(address, sessionId, subscription);
} else {
- log.trace("[{}] Registering local subscription [{}] for device [{}]", sessionId, sub.getSubscriptionId(), entityId);
- subscription = new Subscription(sub, true);
+ log.trace("[{}] Registering local subscription [{}] for [{}] entity [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId);
+ subscription = new Subscription(sub, true, null, startTime, endTime);
}
registerSubscription(sessionId, entityId, subscription);
}
+ private SubscriptionState getUpdatedSubscriptionState(EntityId entityId, SubscriptionState sub, EntityView entityView) {
+ Map<String, Long> keyStates;
+ 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));
+ }
+ return new SubscriptionState(sub.getWsSessionId(), sub.getSubscriptionId(), entityId, sub.getType(), false, keyStates, sub.getScope());
+ }
+
@Override
public void cleanupLocalWsSessionSubscriptions(TelemetryWebSocketSessionRef sessionRef, String sessionId) {
cleanupLocalWsSessionSubscriptions(sessionId);
@@ -232,7 +261,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);
}
@@ -430,7 +459,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio
onLocalSubUpdate(entityId, s -> TelemetryFeature.TIMESERIES == s.getType(), s -> {
List<TsKvEntry> 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<>();
}
@@ -441,6 +470,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<Subscription> filter, Function<Subscription, List<TsKvEntry>> f) {
Set<Subscription> deviceSubscriptions = subscriptionsByEntityId.get(entityId);
if (deviceSubscriptions != null) {
@@ -584,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());
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 2ff8e89..1251ca3 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<ValidationResult> on(Consumer<ValidationResult> success, Consumer<Throwable> failure) {
+ private FutureCallback<ValidationResult> on(Consumer<Void> success, Consumer<Throwable> failure) {
return new FutureCallback<ValidationResult>() {
@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
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 811c055..32d6243 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);
+ public Subscription(SubscriptionState sub, boolean local, ServerAddress server) {
+ this(sub, local, server, 0L, 0L);
}
public String getWsSessionId() {
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 0000000..5c39f8a
--- /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<TransportApiResponseMsg> 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<TransportApiResponseMsg> 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<TransportApiResponseMsg> handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) {
+ DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB()));
+ ListenableFuture<Device> 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<TransportApiResponseMsg> 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<TransportApiResponseMsg> 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 0000000..3d12938
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java
@@ -0,0 +1,214 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+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.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+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 extends AbstractTransportService implements RuleEngineTransportService {
+
+ @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() {
+ super.init();
+ }
+
+ @PreDestroy
+ public void destroy() {
+ super.destroy();
+ }
+
+ @Override
+ public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback<ValidateDeviceCredentialsResponseMsg> 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<ValidateDeviceCredentialsResponseMsg> 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<GetOrCreateDeviceFromGatewayResponseMsg> callback) {
+ DonAsynchron.withCallback(
+ transportApiService.handle(TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()),
+ transportApiResponseMsg -> {
+ if (callback != null) {
+ callback.onSuccess(transportApiResponseMsg.getGetOrCreateDeviceResponseMsg());
+ }
+ },
+ getThrowableConsumer(callback), transportCallbackExecutor);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback);
+ }
+
+ @Override
+ public void process(SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscriptionInfo(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback<Void> callback) {
+ forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback);
+ }
+
+ @Override
+ public void process(String nodeId, DeviceActorToTransportMsg msg) {
+ process(nodeId, msg, null, null);
+ }
+
+ @Override
+ public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer<Throwable> onFailure) {
+ processToTransportMsg(msg);
+ if (onSuccess != null) {
+ onSuccess.run();
+ }
+ }
+
+ private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg, TransportServiceCallback<Void> callback) {
+ TransportToDeviceActorMsgWrapper wrapper = new TransportToDeviceActorMsgWrapper(toDeviceActorMsg);
+ Optional<ServerAddress> 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 <T> Consumer<Throwable> getThrowableConsumer(TransportServiceCallback<T> 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
new file mode 100644
index 0000000..a8ab2cd
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java
@@ -0,0 +1,232 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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;
+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.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;
+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.UUID;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+/**
+ * Created by ashvayka on 09.10.18.
+ */
+@Slf4j
+@Service
+@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote")
+public class RemoteRuleEngineTransportService implements RuleEngineTransportService {
+
+ @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;
+
+ @Value("${transport.remote.rule_engine.poll_records_pack_size}")
+ 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}")
+ private long pollRecordsPerMinute;
+
+ @Autowired
+ private TbKafkaSettings kafkaSettings;
+
+ @Autowired
+ private TbNodeIdProvider nodeIdProvider;
+
+ @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;
+
+ private TBKafkaConsumerTemplate<ToRuleEngineMsg> ruleEngineConsumer;
+ private TBKafkaProducerTemplate<ToTransportMsg> notificationsProducer;
+
+ private ExecutorService mainConsumerExecutor = Executors.newSingleThreadExecutor();
+
+ private volatile boolean stopped = false;
+
+ @PostConstruct
+ public void init() {
+ TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder<ToTransportMsg> notificationsProducerBuilder = TBKafkaProducerTemplate.builder();
+ notificationsProducerBuilder.settings(kafkaSettings);
+ notificationsProducerBuilder.defaultTopic(notificationsTopic);
+ notificationsProducerBuilder.encoder(new ToTransportMsgEncoder());
+
+ notificationsProducer = notificationsProducerBuilder.build();
+ notificationsProducer.init();
+
+ TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder<ToRuleEngineMsg> ruleEngineConsumerBuilder = TBKafkaConsumerTemplate.builder();
+ ruleEngineConsumerBuilder.settings(kafkaSettings);
+ ruleEngineConsumerBuilder.topic(ruleEngineTopic);
+ ruleEngineConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId());
+ 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<String, byte[]> 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());
+ Thread.sleep(TimeUnit.SECONDS.toMillis(1));
+ }
+ log.trace("Processing {} records", recordsCount);
+ }
+ records.forEach(record -> {
+ try {
+ ToRuleEngineMsg toRuleEngineMsg = ruleEngineConsumer.decode(record);
+ log.trace("Forwarding message to rule engine {}", toRuleEngineMsg);
+ 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<Throwable> 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<ServerAddress> 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());
+ }
+ }
+
+ @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<Throwable> onFailure;
+
+ QueueCallbackAdaptor(Runnable onSuccess, Consumer<Throwable> 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/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java
new file mode 100644
index 0000000..6d422f4
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg;
+import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg;
+import org.thingsboard.server.kafka.*;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.SynchronousQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Created by ashvayka on 05.10.18.
+ */
+@Slf4j
+@Component
+@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote")
+public class RemoteTransportApiService {
+
+ @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 TbNodeIdProvider nodeIdProvider;
+
+ @Autowired
+ private TransportApiService transportApiService;
+
+ private ExecutorService transportCallbackExecutor;
+
+ private TbKafkaResponseTemplate<TransportApiRequestMsg, TransportApiResponseMsg> transportApiTemplate;
+
+ @PostConstruct
+ public void init() {
+ this.transportCallbackExecutor = new ThreadPoolExecutor(0, 100, 60L, TimeUnit.SECONDS, new SynchronousQueue<>());
+
+ TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder<TransportApiResponseMsg> responseBuilder = TBKafkaProducerTemplate.builder();
+ responseBuilder.settings(kafkaSettings);
+ responseBuilder.defaultTopic(transportApiResponsesTopic);
+ responseBuilder.encoder(new TransportApiResponseEncoder());
+
+ TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder<TransportApiRequestMsg> requestBuilder = TBKafkaConsumerTemplate.builder();
+ requestBuilder.settings(kafkaSettings);
+ requestBuilder.topic(transportApiRequestsTopic);
+ requestBuilder.clientId(nodeIdProvider.getNodeId());
+ requestBuilder.groupId("tb-node");
+ requestBuilder.autoCommit(true);
+ requestBuilder.autoCommitIntervalMs(autoCommitInterval);
+ requestBuilder.decoder(new TransportApiRequestDecoder());
+
+ TbKafkaResponseTemplate.TbKafkaResponseTemplateBuilder
+ <TransportApiRequestMsg, TransportApiResponseMsg> 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(transportApiService);
+ transportApiTemplate = builder.build();
+ transportApiTemplate.init();
+ }
+
+ @PreDestroy
+ public void destroy() {
+ if (transportApiTemplate != null) {
+ transportApiTemplate.stop();
+ }
+ if (transportCallbackExecutor != null) {
+ transportCallbackExecutor.shutdownNow();
+ }
+ }
+
+}
diff --git a/application/src/main/proto/cluster.proto b/application/src/main/proto/cluster.proto
index 21c963b..1940b36 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
+}
application/src/main/proto/jsinvoke.proto 87(+87 -0)
diff --git a/application/src/main/proto/jsinvoke.proto b/application/src/main/proto/jsinvoke.proto
new file mode 100644
index 0000000..a2afca2
--- /dev/null
+++ b/application/src/main/proto/jsinvoke.proto
@@ -0,0 +1,87 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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 js;
+
+option java_package = "org.thingsboard.server.gen.js";
+option java_outer_classname = "JsInvokeProtos";
+
+enum JsInvokeErrorCode {
+ COMPILATION_ERROR = 0;
+ RUNTIME_ERROR = 1;
+ TIMEOUT_ERROR = 2;
+}
+
+message RemoteJsRequest {
+ string responseTopic = 1;
+ int64 requestIdMSB = 2;
+ int64 requestIdLSB = 3;
+ JsCompileRequest compileRequest = 4;
+ JsInvokeRequest invokeRequest = 5;
+ JsReleaseRequest releaseRequest = 6;
+}
+
+message RemoteJsResponse {
+ int64 requestIdMSB = 1;
+ int64 requestIdLSB = 2;
+ JsCompileResponse compileResponse = 3;
+ JsInvokeResponse invokeResponse = 4;
+ JsReleaseResponse releaseResponse = 5;
+}
+
+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 {
+ int64 scriptIdMSB = 1;
+ int64 scriptIdLSB = 2;
+ string functionName = 3;
+ string scriptBody = 4;
+ int32 timeout = 5;
+ repeated string args = 6;
+}
+
+message JsInvokeResponse {
+ bool success = 1;
+ string result = 2;
+ JsInvokeErrorCode errorCode = 3;
+ string errorDetails = 4;
+}
+
diff --git a/application/src/main/resources/actor-system.conf b/application/src/main/resources/actor-system.conf
index 3c68775..28673f6 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"]
}
@@ -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/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml
index 978a570..dcfc930 100644
--- a/application/src/main/resources/logback.xml
+++ b/application/src/main/resources/logback.xml
@@ -17,7 +17,7 @@
-->
<!DOCTYPE configuration>
-<configuration>
+<configuration scan="true" scanPeriod="10 seconds">
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
application/src/main/resources/thingsboard.yml 255(+143 -112)
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 743a860..60ce107 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:
@@ -77,89 +78,18 @@ 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}"
-# Device communication protocol parameters
-http:
- request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}"
-
-# 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}"
-
-# CoAP server parameters
-coap:
- # Enable/disable coap transport protocol.
- enabled: "${COAP_ENABLED:false}"
- bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}"
- bind_port: "${COAP_BIND_PORT:5683}"
- adaptor: "${COAP_ADAPTOR_NAME:JsonCoapAdaptor}"
- 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: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
- 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:false}"
- # Array of whitelist tenants
- whitelist: "${QUOTA_TENANT_WHITELIST:}"
- # Array of blacklist tenants
- blacklist: "${QUOTA_HOST_BLACKLIST:}"
- log:
- topSize: 10
- intervalMin: 2
+# Dashboard parameters
+dashboard:
+ # Maximum allowed datapoints fetched by widgets
+ max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}"
database:
- type: "${DATABASE_TYPE:sql}" # cassandra OR sql
+ 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:
+ type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra)
+
# Cassandra driver configuration parameters
cassandra:
@@ -181,7 +111,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
@@ -211,13 +142,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}"
-
- queue:
- msg.ttl: 604800 # 7 days
- ack.ttl: 604800 # 7 days
- partitions.ttl: 604800 # 7 days
- partitioning: "HOURS"
+ rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}"
# SQL configuration parameters
sql:
@@ -246,28 +171,12 @@ actors:
allow_system_mail_service: "${ACTORS_RULE_ALLOW_SYSTEM_MAIL_SERVICE:true}"
# Specify thread pool size for external call service
external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:10}"
- js_sandbox:
- # Use Sandboxed (secured) JavaScript environment
- use_js_sandbox: "${ACTORS_RULE_JS_SANDBOX_USE_JS_SANDBOX:true}"
- # Specify thread pool size for JavaScript sandbox resource monitor
- monitor_thread_pool_size: "${ACTORS_RULE_JS_SANDBOX_MONITOR_THREAD_POOL_SIZE:4}"
- # Maximum CPU time in milliseconds allowed for script execution
- max_cpu_time: "${ACTORS_RULE_JS_SANDBOX_MAX_CPU_TIME:100}"
- # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted
- max_errors: "${ACTORS_RULE_JS_SANDBOX_MAX_ERRORS:3}"
chain:
# Errors for particular actor are persisted once per specified amount of milliseconds
error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}"
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}"
@@ -295,9 +204,15 @@ caffeine:
devices:
timeToLiveInMinutes: 1440
maxSize: 100000
+ sessions:
+ timeToLiveInMinutes: 1440
+ maxSize: 100000
assets:
timeToLiveInMinutes: 1440
maxSize: 100000
+ entityViews:
+ timeToLiveInMinutes: 1440
+ maxSize: 100000
redis:
# standalone or cluster
@@ -312,7 +227,7 @@ redis:
updates:
# Enable/disable updates checking.
enabled: "${UPDATES_ENABLED:true}"
-
+
# spring CORS configuration
spring.mvc.cors:
mappings:
@@ -359,7 +274,7 @@ spring:
password: "${SPRING_DATASOURCE_PASSWORD:}"
# PostgreSQL DAO Configuration
-#spring:
+# spring:
# data:
# sql:
# repositories:
@@ -393,6 +308,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}"
@@ -411,8 +327,123 @@ audit_log:
password: "${AUDIT_LOG_SINK_PASSWORD:}"
state:
- defaultInactivityTimeoutInSec: 10
- defaultStateCheckIntervalInSec: 10
-# TODO in v2.1
-# defaultStatePersistenceIntervalInSec: 60
-# defaultStatePersistencePack: 100
\ No newline at end of file
+ defaultInactivityTimeoutInSec: "${DEFAULT_INACTIVITY_TIMEOUT:10}"
+ defaultStateCheckIntervalInSec: "${DEFAULT_STATE_CHECK_INTERVAL: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}"
+ 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
+ # Built-in JVM JavaScript environment properties
+ local:
+ # Use Sandboxed (secured) JVM JavaScript environment
+ use_js_sandbox: "${USE_LOCAL_JS_SANDBOX:true}"
+ # Specify thread pool size for JavaScript sandbox resource monitor
+ monitor_thread_pool_size: "${LOCAL_JS_SANDBOX_MONITOR_THREAD_POOL_SIZE:4}"
+ # Maximum CPU time in milliseconds allowed for script execution
+ max_cpu_time: "${LOCAL_JS_SANDBOX_MAX_CPU_TIME:100}"
+ # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted
+ max_errors: "${LOCAL_JS_SANDBOX_MAX_ERRORS:3}"
+ # Remote JavaScript environment properties
+ remote:
+ # 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: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}"
+
+transport:
+ type: "${TRANSPORT_TYPE:local}" # local or remote
+ remote:
+ 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}"
+ 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_MINUTE:120000}"
+ 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}"
+ 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}"
+ request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}"
+ # Local MQTT transport 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}"
+ timeout: "${MQTT_TIMEOUT:10000}"
+ netty:
+ 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}"
+ # 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}"
+ # Local CoAP transport parameters
+ coap:
+ # Enable/disable coap transport protocol.
+ enabled: "${COAP_ENABLED:true}"
+ bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}"
+ bind_port: "${COAP_BIND_PORT:5683}"
+ timeout: "${COAP_TIMEOUT:10000}"
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 1b042a8..cb31a4b 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/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java
new file mode 100644
index 0000000..31869f7
--- /dev/null
+++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java
@@ -0,0 +1,533 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.controller;
+
+import com.datastax.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;
+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;
+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.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;
+
+public abstract class BaseEntityViewControllerTest extends AbstractControllerTest {
+
+ private IdComparator<EntityView> idComparator;
+ private Tenant savedTenant;
+ private User tenantAdmin;
+ private Device testDevice;
+ private TelemetryEntityView telemetry;
+
+ @Before
+ public void beforeTest() throws Exception {
+ loginSysAdmin();
+ idComparator = new IdComparator<>();
+
+ savedTenant = doPost("/api/tenant", getNewTenant("My 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");
+
+ Device device = new Device();
+ device.setName("Test device");
+ device.setType("default");
+ testDevice = doPost("/api/device", device, Device.class);
+
+ telemetry = new TelemetryEntityView(
+ Arrays.asList("tsKey1", "tsKey2", "tsKey3"),
+ new AttributesEntityView(
+ Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4"),
+ Arrays.asList("saKey1", "saKey2", "saKey3", "saKey4"),
+ Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4")));
+ }
+
+ @After
+ public void afterTest() throws Exception {
+ loginSysAdmin();
+
+ doDelete("/api/tenant/" + savedTenant.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+
+ @Test
+ public void testFindEntityViewById() throws Exception {
+ EntityView savedView = getNewSavedEntityView("Test entity view");
+ EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class);
+ Assert.assertNotNull(foundView);
+ assertEquals(savedView, foundView);
+ }
+
+ @Test
+ public void testSaveEntityView() throws Exception {
+ EntityView savedView = getNewSavedEntityView("Test entity view");
+
+ Assert.assertNotNull(savedView);
+ Assert.assertNotNull(savedView.getId());
+ Assert.assertTrue(savedView.getCreatedTime() > 0);
+ assertEquals(savedTenant.getId(), savedView.getTenantId());
+ Assert.assertNotNull(savedView.getCustomerId());
+ 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);
+
+ assertEquals(foundEntityView.getName(), savedView.getName());
+ assertEquals(foundEntityView.getKeys(), telemetry);
+ }
+
+ @Test
+ public void testDeleteEntityView() throws Exception {
+ 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);
+
+ 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 {
+ EntityView entityView = new EntityView();
+ entityView.setType("default");
+ doPost("/api/entityView", entityView)
+ .andExpect(status().isBadRequest())
+ .andExpect(statusReason(containsString("Entity view name should be specified!")));
+ }
+
+ @Test
+ public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception {
+ 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);
+
+ EntityView assignedView = doPost(
+ "/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString(),
+ EntityView.class);
+ assertEquals(savedCustomer.getId(), assignedView.getCustomerId());
+
+ EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class);
+ assertEquals(savedCustomer.getId(), foundView.getCustomerId());
+
+ EntityView unAssignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class);
+ assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId());
+
+ foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class);
+ assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId());
+ }
+
+ @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
+ public void testAssignEntityViewToCustomerFromDifferentTenant() throws Exception {
+ loginSysAdmin();
+
+ 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");
+ createUserAndLogin(tenantAdmin2, "testPassword1");
+
+ Customer customer = getNewCustomer("Different customer");
+ Customer savedCustomer = doPost("/api/customer", customer, Customer.class);
+
+ login(tenantAdmin.getEmail(), "testPassword1");
+
+ EntityView savedView = getNewSavedEntityView("Test entity view");
+
+ doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString())
+ .andExpect(status().isForbidden());
+
+ loginSysAdmin();
+
+ 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() + "/entityViews?";
+
+ List<EntityView> views = new ArrayList<>();
+ for (int i = 0; i < 128; i++) {
+ views.add(doPost("/api/customer/" + customerId.getId().toString() + "/entityView/"
+ + getNewSavedEntityView("Test entity view " + i).getId().getId().toString(), EntityView.class));
+ }
+
+ List<EntityView> loadedViews = loadListOf(new TextPageLink(23), urlTemplate);
+
+ Collections.sort(views, idComparator);
+ Collections.sort(loadedViews, idComparator);
+
+ 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() + "/entityViews?";
+
+ String name1 = "Entity view name1";
+ List<EntityView> namesOfView1 = fillListOf(125, name1, "/api/customer/" + customerId.getId().toString()
+ + "/entityView/");
+ List<EntityView> loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), urlTemplate);
+ Collections.sort(namesOfView1, idComparator);
+ Collections.sort(loadedNamesOfView1, idComparator);
+ assertEquals(namesOfView1, loadedNamesOfView1);
+
+ String name2 = "Entity view name2";
+ List<EntityView> NamesOfView2 = fillListOf(143, name2, "/api/customer/" + customerId.getId().toString()
+ + "/entityView/");
+ List<EntityView> loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), urlTemplate);
+ Collections.sort(NamesOfView2, idComparator);
+ Collections.sort(loadedNamesOfView2, idComparator);
+ assertEquals(NamesOfView2, loadedNamesOfView2);
+
+ for (EntityView view : loadedNamesOfView1) {
+ doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk());
+ }
+ TextPageData<EntityView> pageData = doGetTypedWithPageLink(urlTemplate,
+ new TypeReference<TextPageData<EntityView>>() {
+ }, new TextPageLink(4, name1));
+ Assert.assertFalse(pageData.hasNext());
+ 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<TextPageData<EntityView>>() {
+ },
+ new TextPageLink(4, name2));
+ Assert.assertFalse(pageData.hasNext());
+ assertEquals(0, pageData.getData().size());
+ }
+
+ @Test
+ public void testGetTenantEntityViews() throws Exception {
+
+ List<EntityView> views = new ArrayList<>();
+ for (int i = 0; i < 178; i++) {
+ views.add(getNewSavedEntityView("Test entity view" + i));
+ }
+ List<EntityView> loadedViews = loadListOf(new TextPageLink(23), "/api/tenant/entityViews?");
+
+ Collections.sort(views, idComparator);
+ Collections.sort(loadedViews, idComparator);
+
+ assertEquals(views, loadedViews);
+ }
+
+ @Test
+ public void testGetTenantEntityViewsByName() throws Exception {
+ String name1 = "Entity view name1";
+ List<EntityView> namesOfView1 = fillListOf(143, name1);
+ List<EntityView> loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), "/api/tenant/entityViews?");
+ Collections.sort(namesOfView1, idComparator);
+ Collections.sort(loadedNamesOfView1, idComparator);
+ assertEquals(namesOfView1, loadedNamesOfView1);
+
+ String name2 = "Entity view name2";
+ List<EntityView> NamesOfView2 = fillListOf(75, name2);
+ List<EntityView> loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), "/api/tenant/entityViews?");
+ Collections.sort(NamesOfView2, idComparator);
+ Collections.sort(loadedNamesOfView2, idComparator);
+ assertEquals(NamesOfView2, loadedNamesOfView2);
+
+ for (EntityView view : loadedNamesOfView1) {
+ doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk());
+ }
+ TextPageData<EntityView> pageData = doGetTypedWithPageLink("/api/tenant/entityViews?",
+ new TypeReference<TextPageData<EntityView>>() {
+ }, new TextPageLink(4, name1));
+ Assert.assertFalse(pageData.hasNext());
+ 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<TextPageData<EntityView>>() {
+ },
+ new TextPageLink(4, name2));
+ Assert.assertFalse(pageData.hasNext());
+ assertEquals(0, pageData.getData().size());
+ }
+
+ @Test
+ public void testTheCopyOfAttrsIntoTSForTheView() throws Exception {
+ Set<String> actualAttributesSet =
+ getAttributesByKeys("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}");
+
+ Set<String> expectedActualAttributesSet =
+ new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4"));
+ assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet));
+
+ EntityView savedView = getNewSavedEntityView("Test entity view");
+
+ Thread.sleep(1000);
+
+ List<Map<String, Object>> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() +
+ "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class);
+
+ 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<String> actualAttributesSet =
+ getAttributesByKeys("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}");
+
+ Set<String> expectedActualAttributesSet = new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4"));
+ assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet));
+
+ List<Map<String, Object>> valueTelemetryOfDevices = 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.setType("default");
+ view.setKeys(telemetry);
+ view.setStartTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") * 10);
+ view.setEndTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") / 10);
+ EntityView savedView = doPost("/api/entityView", view, EntityView.class);
+
+ Thread.sleep(1000);
+
+ List<Map<String, Object>> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() +
+ "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class);
+ 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<String> 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<String, List<Map<String, String>>> 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<String, List<Map<String, String>>> 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<String> getTelemetryKeys(String type, String id) throws Exception {
+ return new HashSet<>(doGetAsync("/api/plugins/telemetry/" + type + "/" + id + "/keys/timeseries", List.class));
+ }
+
+ private Map<String, List<Map<String, String>>> getTelemetryValues(String type, String id, Set<String> 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<String> 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();
+ 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((stringKV).getBytes());
+ client.publish("v1/devices/me/attributes", message);
+ Thread.sleep(1000);
+
+ return new HashSet<>(doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class));
+ }
+
+ private Object getValue(List<Map<String, Object>> values, String stringValue) {
+ 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 {
+ 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);
+ view.setStartTimeMs(startTimeMs);
+ view.setEndTimeMs(endTimeMs);
+ return view;
+ }
+
+ 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<EntityView> fillListOf(int limit, String partOfName, String urlTemplate) throws Exception {
+ List<EntityView> views = new ArrayList<>();
+ for (EntityView view : fillListOf(limit, partOfName)) {
+ views.add(doPost(urlTemplate + view.getId().getId().toString(), EntityView.class));
+ }
+ return views;
+ }
+
+ private List<EntityView> fillListOf(int limit, String partOfName) throws Exception {
+ List<EntityView> viewNames = new ArrayList<>();
+ for (int i = 0; i < limit; i++) {
+ String fullName = partOfName + ' ' + RandomStringUtils.randomAlphanumeric(15);
+ fullName = i % 2 == 0 ? fullName.toLowerCase() : fullName.toUpperCase();
+ 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));
+ }
+ return viewNames;
+ }
+
+ private List<EntityView> loadListOf(TextPageLink pageLink, String urlTemplate) throws Exception {
+ List<EntityView> loadedItems = new ArrayList<>();
+ TextPageData<EntityView> pageData;
+ do {
+ pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference<TextPageData<EntityView>>() {
+ }, pageLink);
+ loadedItems.addAll(pageData.getData());
+ if (pageData.hasNext()) {
+ pageLink = pageData.getNextPageLink();
+ }
+ } while (pageData.hasNext());
+
+ return loadedItems;
+ }
+}
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 2e88483..f378437 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 f316051..cdfa001 100644
--- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java
+++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java
@@ -24,13 +24,13 @@ import java.util.Arrays;
@RunWith(ClasspathSuite.class)
@ClasspathSuite.ClassnameFilters({
- "org.thingsboard.server.controller.sql.*SqlTest",
+ "org.thingsboard.server.controller.sql.*Test",
})
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 c4a969b..2bb8a81 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 5ddbb67..1389c7e 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/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java
index f3e29dc..7453682 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));
+
+ CountDownLatch latch = new CountDownLatch(1);
+ 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}}";
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
@@ -156,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();
@@ -204,9 +211,16 @@ 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() {
+ return qoS;
}
@Override
@@ -219,7 +233,9 @@ 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);
+ 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 d02d07d..f7a7cea 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,62 @@ 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);
+ CountDownLatch latch = new CountDownLatch(1);
+ 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());
+ 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 final CountDownLatch latch;
+ private Integer qoS;
+ private String payload;
+
+ String getPayload() {
+ return payload;
+ }
+
+ TestMqttCallback(MqttAsyncClient client, CountDownLatch latch) {
+ this.client = client;
+ this.latch = latch;
+ }
+
+ 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();
+ latch.countDown();
+ }
+
+ @Override
+ public void deliveryComplete(IMqttDeliveryToken iMqttDeliveryToken) {
+
+ }
+ }
+
+
}
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 c86d496..1de2787 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);
@@ -191,9 +192,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<TbMsg> unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), ruleChain.getId().getId(), 0L));
- Assert.assertEquals(0, unAckMsgList.size());
}
@Test
@@ -273,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);
@@ -311,12 +309,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<TbMsg> 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 7ac0789..f59dd63 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,76 +143,7 @@ public abstract class AbstractRuleEngineLifecycleIntegrationTest extends Abstrac
new TbMsgMetaData(),
"{}",
null, null, 0L);
- actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg));
-
- Thread.sleep(3000);
-
- TimePageData<Event> eventsPage = getDebugEvents(savedTenant.getId(), ruleChain.getFirstRuleNodeId(), 1000);
- List<Event> 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());
- }
-
- @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());
+ 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/RuleEngineNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java
index bffe491..c1d1e6c 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 7b13e2f..e09d820 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/service/script/RuleNodeJsScriptEngineTest.java b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java
index ea70384..fe0f381 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,23 +21,30 @@ 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.AtomicInteger;
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
@@ -48,7 +55,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 +72,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 +88,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 +106,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 +120,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 +141,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 +163,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 +175,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<UUID, Object> 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<UUID, Object> 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<UUID, Object> 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, "{}", "{}", "TEXT").get();
+ jsSandboxService.release(scriptId).get();
+ }
+ } catch (Throwable th) {
+ failedCount.incrementAndGet();
+ } finally {
+ finishLatch.countDown();
+ }
+ }
+
}
\ No newline at end of file
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 5e6896b..1ebccad 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<String, String> attrMap = new HashMap<>();
attrMap.put("keyA", "valueA");
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 70e3fe2..ebde304 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 97c6749..8ca6dcc 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/application/src/test/resources/logback.xml b/application/src/test/resources/logback.xml
index b77027a..47dacce 100644
--- a/application/src/test/resources/logback.xml
+++ b/application/src/test/resources/logback.xml
@@ -9,7 +9,7 @@
<logger name="org.thingsboard.server" level="WARN"/>
<logger name="org.springframework" level="WARN"/>
- <logger name="org.springframework.boot.test" level="DEBUG"/>
+ <logger name="org.springframework.boot.test" level="WARN"/>
<logger name="org.apache.cassandra" level="WARN"/>
<logger name="org.cassandraunit" level="INFO"/>
common/data/pom.xml 2(+1 -1)
diff --git a/common/data/pom.xml b/common/data/pom.xml
index b99e6bf..0fef87a 100644
--- a/common/data/pom.xml
+++ b/common/data/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>
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 c37d460..822387c 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/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 21de402..853caff 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,5 +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/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 34c14de..307c59b 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/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 fe9c018..ef4994a 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/EntityViewSearchQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java
new file mode 100644
index 0000000..3bf08ea
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.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.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;
+ private List<String> entityViewTypes;
+
+ 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/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 ed4cf2f..4e35c0b 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!");
}
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 3c48adf..3e4e8ef 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.NONE, orderBy);
+ }
+
}
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 0000000..d125f26
--- /dev/null
+++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.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.common.data.objects;
+
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Created by Victor Basanets on 9/05/2017.
+ */
+@Data
+@NoArgsConstructor
+public class AttributesEntityView implements Serializable {
+
+ private List<String> cs = new ArrayList<>();
+ private List<String> ss = new ArrayList<>();
+ private List<String> sh = new ArrayList<>();
+
+ public AttributesEntityView(List<String> cs,
+ List<String> ss,
+ List<String> 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());
+ }
+}
common/message/pom.xml 6(+5 -1)
diff --git a/common/message/pom.xml b/common/message/pom.xml
index 6923cac..d914d4f 100644
--- a/common/message/pom.xml
+++ b/common/message/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>
@@ -41,6 +41,10 @@
<artifactId>data</artifactId>
</dependency>
<dependency>
+ <groupId>org.bouncycastle</groupId>
+ <artifactId>bcprov-jdk15on</artifactId>
+ </dependency>
+ <dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
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 4b65d6f..60c9d12 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<ServerAddress>, Serializable {
private final String host;
private final int port;
+ private final ServerType serverType;
@Override
public int compareTo(ServerAddress o) {
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 82f44e9..fa9ef05 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/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java
index 60e5469..44a98d6 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,
@@ -96,21 +91,19 @@ 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
*/
DEVICE_ACTOR_TO_RULE_ENGINE_MSG,
+ SESSION_TIMEOUT_MSG,
+
+ STATS_PERSIST_TICK_MSG,
+
+
/**
- * Message that is sent from Rule Engine to the Device Actor when message is successfully pushed to queue.
+ * Message that is sent by TransportRuleEngineService to Device Actor. Represents messages from the device itself.
*/
- RULE_ENGINE_QUEUE_PUT_ACK_MSG,
- ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG,
- TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG,
- SESSION_TIMEOUT_MSG,
- SESSION_CTRL_MSG,
- STATS_PERSIST_TICK_MSG;
+ TRANSPORT_TO_DEVICE_ACTOR_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 73aaab4..7f7a669 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,20 +15,11 @@
*/
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.msg.session.ex.SessionException;
+import java.util.UUID;
-public interface SessionContext extends SessionAwareMsg {
+public interface SessionContext {
- 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/system/ServiceToRuleEngineMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java
index 0792b63..7859623 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;
common/pom.xml 6(+3 -3)
diff --git a/common/pom.xml b/common/pom.xml
index fbff206..e55d71f 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -20,10 +20,9 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
- <groupId>org.thingsboard</groupId>
<artifactId>common</artifactId>
<packaging>pom</packaging>
@@ -37,7 +36,8 @@
<modules>
<module>data</module>
<module>message</module>
- <module>transport</module>
+ <module>queue</module>
+ <module>transport</module>
</modules>
</project>
common/queue/pom.xml 99(+99 -0)
diff --git a/common/queue/pom.xml b/common/queue/pom.xml
new file mode 100644
index 0000000..765960e
--- /dev/null
+++ b/common/queue/pom.xml
@@ -0,0 +1,99 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>common</artifactId>
+ </parent>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
+ <packaging>jar</packaging>
+
+ <name>Thingsboard Server Queue components</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../..</main.dir>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>data</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>message</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.kafka</groupId>
+ <artifactId>kafka-clients</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context-support</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-autoconfigure</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>log4j-over-slf4j</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+</project>
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 0000000..b8ad758
--- /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 <T> void withCallbackAndTimeout(ListenableFuture<T> future,
+ Consumer<T> onSuccess,
+ Consumer<Throwable> onFailure,
+ long timeoutInMs,
+ ScheduledExecutorService timeoutExecutor,
+ Executor callbackExecutor) {
+ future = Futures.withTimeout(future, timeoutInMs, TimeUnit.MILLISECONDS, timeoutExecutor);
+ withCallback(future, onSuccess, onFailure, callbackExecutor);
+ }
+
+ public static <T> void withCallback(ListenableFuture<T> future, Consumer<T> onSuccess,
+ Consumer<Throwable> onFailure, Executor executor) {
+ FutureCallback<T> callback = new FutureCallback<T>() {
+ @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/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java
new file mode 100644
index 0000000..c8a1706
--- /dev/null
+++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java
@@ -0,0 +1,88 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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;
+import java.util.UUID;
+
+/**
+ * Created by ashvayka on 24.09.18.
+ */
+public class TBKafkaConsumerTemplate<T> {
+
+ private final KafkaConsumer<String, byte[]> consumer;
+ private final TbKafkaDecoder<T> decoder;
+
+ @Builder.Default
+ private TbKafkaRequestIdExtractor<T> requestIdExtractor = ((response) -> null);
+
+ @Getter
+ private final String topic;
+
+ @Builder
+ private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder<T> decoder,
+ TbKafkaRequestIdExtractor<T> requestIdExtractor,
+ String clientId, String groupId, String topic,
+ boolean autoCommit, int autoCommitIntervalMs,
+ int maxPollRecords) {
+ Properties props = settings.toProps();
+ props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId);
+ 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");
+ props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer");
+ if (maxPollRecords > 0) {
+ props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords);
+ }
+ this.consumer = new KafkaConsumer<>(props);
+ this.decoder = decoder;
+ this.requestIdExtractor = requestIdExtractor;
+ this.topic = topic;
+ }
+
+ public void subscribe() {
+ consumer.subscribe(Collections.singletonList(topic));
+ }
+
+ public void unsubscribe() {
+ consumer.unsubscribe();
+ }
+
+ public ConsumerRecords<String, byte[]> poll(Duration duration) {
+ return consumer.poll(duration);
+ }
+
+ public T decode(ConsumerRecord<String, byte[]> 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
new file mode 100644
index 0000000..ee652f4
--- /dev/null
+++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java
@@ -0,0 +1,134 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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;
+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;
+import java.util.Properties;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.Future;
+
+/**
+ * Created by ashvayka on 24.09.18.
+ */
+@Slf4j
+public class TBKafkaProducerTemplate<T> {
+
+ private final KafkaProducer<String, byte[]> producer;
+ private final TbKafkaEncoder<T> encoder;
+
+ @Builder.Default
+ private TbKafkaEnricher<T> enricher = ((value, responseTopic, requestId) -> value);
+
+ private final TbKafkaPartitioner<T> partitioner;
+ private ConcurrentMap<String, List<PartitionInfo>> partitionInfoMap;
+ @Getter
+ private final String defaultTopic;
+
+ @Getter
+ private final TbKafkaSettings settings;
+
+ @Builder
+ private TBKafkaProducerTemplate(TbKafkaSettings settings, TbKafkaEncoder<T> encoder, TbKafkaEnricher<T> enricher,
+ TbKafkaPartitioner<T> 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;
+ this.partitioner = partitioner;
+ this.defaultTopic = defaultTopic;
+ }
+
+ public void init() {
+ try {
+ TBKafkaAdmin admin = new TBKafkaAdmin(this.settings);
+ CreateTopicsResult result = admin.createTopic(new NewTopic(defaultTopic, 100, (short) 1));
+ result.all().get();
+ } catch (Exception e) {
+ if ((e instanceof TopicExistsException) || (e.getCause() != null && e.getCause() instanceof TopicExistsException)) {
+ log.trace("[{}] Topic already exists.", defaultTopic);
+ } else {
+ 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
+ this.partitionInfoMap = new ConcurrentHashMap<>();
+ this.partitionInfoMap.putIfAbsent(defaultTopic, producer.partitionsFor(defaultTopic));
+ }
+
+ T enrich(T value, String responseTopic, UUID requestId) {
+ if (enricher != null) {
+ return enricher.enrich(value, responseTopic, requestId);
+ } else {
+ return value;
+ }
+ }
+
+ public Future<RecordMetadata> send(String key, T value, Callback callback) {
+ return send(key, value, null, callback);
+ }
+
+ public Future<RecordMetadata> send(String key, T value, Iterable<Header> headers, Callback callback) {
+ return send(key, value, null, headers, callback);
+ }
+
+ public Future<RecordMetadata> send(String key, T value, Long timestamp, Iterable<Header> headers, Callback callback) {
+ return send(this.defaultTopic, key, value, timestamp, headers, callback);
+ }
+
+ public Future<RecordMetadata> send(String topic, String key, T value, Iterable<Header> headers, Callback callback) {
+ return send(topic, key, value, null, headers, callback);
+ }
+
+ public Future<RecordMetadata> send(String topic, String key, T value, Callback callback) {
+ return send(topic, key, value, null, null, callback);
+ }
+
+ public Future<RecordMetadata> send(String topic, String key, T value, Long timestamp, Iterable<Header> headers, Callback callback) {
+ byte[] data = encoder.encode(value);
+ ProducerRecord<String, byte[]> record;
+ Integer partition = getPartition(topic, key, value, data);
+ record = new ProducerRecord<>(topic, partition, timestamp, key, data, headers);
+ return producer.send(record, callback);
+ }
+
+ private Integer getPartition(String topic, String key, T value, byte[] data) {
+ if (partitioner == null) {
+ return null;
+ } else {
+ return partitioner.partition(topic, key, value, data, partitionInfoMap.computeIfAbsent(topic, producer::partitionsFor));
+ }
+ }
+}
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 0000000..b18ccc2
--- /dev/null
+++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java
@@ -0,0 +1,200 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+
+import java.io.IOException;
+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<Request, Response> extends AbstractTbKafkaTemplate {
+
+ private final TBKafkaProducerTemplate<Request> requestTemplate;
+ private final TBKafkaConsumerTemplate<Response> responseTemplate;
+ private final ConcurrentMap<UUID, ResponseMetaData<Response>> pendingRequests;
+ private final boolean internalExecutor;
+ 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<Request> requestTemplate,
+ TBKafkaConsumerTemplate<Response> 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) {
+ internalExecutor = false;
+ this.executor = executor;
+ } else {
+ internalExecutor = true;
+ this.executor = Executors.newSingleThreadExecutor();
+ }
+ }
+
+ public void init() {
+ try {
+ TBKafkaAdmin admin = new TBKafkaAdmin(this.requestTemplate.getSettings());
+ CreateTopicsResult result = admin.createTopic(new NewTopic(responseTemplate.getTopic(), 1, (short) 1));
+ result.all().get();
+ } catch (Exception 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();
+ responseTemplate.subscribe();
+ executor.submit(() -> {
+ long nextCleanupMs = 0L;
+ while (!stopped) {
+ ConsumerRecords<String, byte[]> responses = responseTemplate.poll(Duration.ofMillis(pollInterval));
+ if (responses.count() > 0) {
+ log.trace("Polling responses completed, consumer records count [{}]", responses.count());
+ }
+ 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;
+ if (requestIdHeader == null) {
+ try {
+ decodedResponse = responseTemplate.decode(response);
+ requestId = responseTemplate.extractRequestId(decodedResponse);
+ } catch (IOException e) {
+ log.error("Failed to decode response", e);
+ }
+ } else {
+ requestId = bytesToUuid(requestIdHeader.value());
+ }
+ if (requestId == null) {
+ log.error("[{}] Missing requestId in header and body", response);
+ } else {
+ log.trace("[{}] Response received", requestId);
+ ResponseMetaData<Response> expectedResponse = pendingRequests.remove(requestId);
+ if (expectedResponse == null) {
+ log.trace("[{}] Invalid or stale request", requestId);
+ } else {
+ try {
+ if (decodedResponse == null) {
+ decodedResponse = responseTemplate.decode(response);
+ }
+ expectedResponse.future.set(decodedResponse);
+ } 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<Response> 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());
+ }
+ }
+ });
+ nextCleanupMs = tickTs + maxRequestTimeout;
+ }
+ }
+ });
+ }
+
+ public void stop() {
+ stopped = true;
+ if (internalExecutor) {
+ executor.shutdownNow();
+ }
+ }
+
+ public ListenableFuture<Response> post(String key, Request request) {
+ if (tickSize > maxPendingRequests) {
+ return Futures.immediateFailedFuture(new RuntimeException("Pending request map is full!"));
+ }
+ UUID requestId = UUID.randomUUID();
+ List<Header> 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<Response> future = SettableFuture.create();
+ ResponseMetaData<Response> 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, (metadata, exception) -> {
+ if (exception != null) {
+ log.trace("[{}] Failed to post the request", requestId, exception);
+ } else {
+ log.trace("[{}] Posted the request", requestId, metadata);
+ }
+ });
+ return future;
+ }
+
+ private static class ResponseMetaData<T> {
+ private final long expTime;
+ private final SettableFuture<T> future;
+
+ ResponseMetaData(long ts, SettableFuture<T> future) {
+ this.expTime = ts;
+ this.future = 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
new file mode 100644
index 0000000..e10cd3c
--- /dev/null
+++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+
+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;
+
+/**
+ * Created by ashvayka on 25.09.18.
+ */
+@Slf4j
+public class TbKafkaResponseTemplate<Request, Response> extends AbstractTbKafkaTemplate {
+
+ private final TBKafkaConsumerTemplate<Request> requestTemplate;
+ private final TBKafkaProducerTemplate<Response> responseTemplate;
+ private final TbKafkaHandler<Request, Response> handler;
+ private final ConcurrentMap<UUID, String> pendingRequests;
+ 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;
+ private final AtomicInteger pendingRequestCount = new AtomicInteger();
+
+ @Builder
+ public TbKafkaResponseTemplate(TBKafkaConsumerTemplate<Request> requestTemplate,
+ TBKafkaProducerTemplate<Response> responseTemplate,
+ TbKafkaHandler<Request, Response> handler,
+ long pollInterval,
+ long requestTimeout,
+ int maxPendingRequests,
+ ExecutorService executor) {
+ this.requestTemplate = requestTemplate;
+ this.responseTemplate = responseTemplate;
+ this.handler = handler;
+ this.pendingRequests = new ConcurrentHashMap<>();
+ this.maxPendingRequests = maxPendingRequests;
+ this.pollInterval = pollInterval;
+ this.requestTimeout = requestTimeout;
+ this.callbackExecutor = executor;
+ this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor();
+ this.loopExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ public void init() {
+ this.responseTemplate.init();
+ requestTemplate.subscribe();
+ loopExecutor.submit(() -> {
+ while (!stopped) {
+ 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<String, byte[]> 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 (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 {
+ Thread.sleep(pollInterval);
+ } catch (InterruptedException e2) {
+ log.trace("Failed to wait until the server has capacity to handle new requests", e2);
+ }
+ }
+ }
+ });
+ }
+
+ 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, requestId.toString(), response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))), null);
+ }
+
+}
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 0000000..39797fc
--- /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.servers}")
+ private String servers;
+
+ @Value("${kafka.acks}")
+ private String acks;
+
+ @Value("${kafka.retries}")
+ private int retries;
+
+ @Value("${kafka.batch.size}")
+ private int batchSize;
+
+ @Value("${kafka.linger.ms}")
+ private long lingerMs;
+
+ @Value("${kafka.buffer.memory}")
+ private long bufferMemory;
+
+ @Value("${kafka.other:#{null}}")
+ private List<TbKafkaProperty> 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;
+ }
+}
common/queue/src/main/resources/logback.xml 35(+35 -0)
diff --git a/common/queue/src/main/resources/logback.xml b/common/queue/src/main/resources/logback.xml
new file mode 100644
index 0000000..dcfc930
--- /dev/null
+++ b/common/queue/src/main/resources/logback.xml
@@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+ <logger name="akka" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ No newline at end of file
common/transport/coap/pom.xml 88(+88 -0)
diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml
new file mode 100644
index 0000000..d68a6db
--- /dev/null
+++ b/common/transport/coap/pom.xml
@@ -0,0 +1,88 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.common</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>coap</artifactId>
+ <packaging>jar</packaging>
+
+ <name>Thingsboard CoAP Transport Common</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.eclipse.californium</groupId>
+ <artifactId>californium-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context-support</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>log4j-over-slf4j</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+</project>
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 0000000..9e48cd3
--- /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 0000000..f5bfb49
--- /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<String> queryElements = inbound.getOptions().getUriQuery();
+ TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder();
+ if (queryElements != null && queryElements.size() > 0) {
+ Set<String> clientKeys = toKeys(queryElements, "clientKeys");
+ Set<String> 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<Integer> 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<String> toKeys(List<String> 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/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 0000000..13662cf
--- /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 0000000..9cf36e3
--- /dev/null
+++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java
@@ -0,0 +1,433 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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<String, TransportProtos.SessionInfoProto> 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) {
+ Optional<FeatureType> 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) {
+ Optional<FeatureType> 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<Integer> 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<DeviceTokenCredentials> 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<DeviceTokenCredentials> decodeCredentials(Request request) {
+ List<String> 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<FeatureType> getFeatureType(Request request) {
+ List<String> 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<Integer> getRequestId(Request request) {
+ List<String> 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<TransportProtos.ValidateDeviceCredentialsResponseMsg> {
+ private final TransportContext transportContext;
+ private final CoapExchange exchange;
+ private final Consumer<TransportProtos.SessionInfoProto> onSuccess;
+
+ DeviceAuthCallback(TransportContext transportContext, CoapExchange exchange, Consumer<TransportProtos.SessionInfoProto> 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<Void> {
+ 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<Void> {
+ 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);
+ }
+
+}
common/transport/http/pom.xml 81(+81 -0)
diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml
new file mode 100644
index 0000000..5d735c9
--- /dev/null
+++ b/common/transport/http/pom.xml
@@ -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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.common</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>http</artifactId>
+ <packaging>jar</packaging>
+
+ <name>Thingsboard HTTP Transport Common</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>log4j-over-slf4j</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+</project>
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
new file mode 100644
index 0000000..18458d1
--- /dev/null
+++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java
@@ -0,0 +1,295 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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;
+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.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.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;
+import java.util.List;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+/**
+ * @author Andrew Shvayka
+ */
+@RestController
+@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')")
+@RequestMapping("/api/v1")
+@Slf4j
+public class DeviceApiController {
+
+ @Autowired
+ private HttpTransportContext transportContext;
+
+ @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.GET, produces = "application/json")
+ public DeferredResult<ResponseEntity> getDeviceAttributes(@PathVariable("deviceToken") String deviceToken,
+ @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys,
+ @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys,
+ HttpServletRequest httpRequest) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<>();
+ transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(),
+ new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> {
+ GetAttributeRequestMsg.Builder request = GetAttributeRequestMsg.newBuilder().setRequestId(0);
+ List<String> clientKeySet = !StringUtils.isEmpty(clientKeys) ? Arrays.asList(clientKeys.split(",")) : null;
+ List<String> 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.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), transportContext.getDefaultTimeout());
+ transportService.process(sessionInfo, request.build(), new SessionCloseOnErrorCallback(transportService, sessionInfo));
+ }));
+ return responseWriter;
+ }
+
+ @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.POST)
+ public DeferredResult<ResponseEntity> postDeviceAttributes(@PathVariable("deviceToken") String deviceToken,
+ @RequestBody String json, HttpServletRequest request) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<>();
+ 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;
+ }
+
+ @RequestMapping(value = "/{deviceToken}/telemetry", method = RequestMethod.POST)
+ public DeferredResult<ResponseEntity> postTelemetry(@PathVariable("deviceToken") String deviceToken,
+ @RequestBody String json, HttpServletRequest request) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<ResponseEntity>();
+ 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<ResponseEntity> subscribeToCommands(@PathVariable("deviceToken") String deviceToken,
+ @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout,
+ HttpServletRequest httpRequest) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<>();
+ 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 responseWriter;
+ }
+
+ @RequestMapping(value = "/{deviceToken}/rpc/{requestId}", method = RequestMethod.POST)
+ public DeferredResult<ResponseEntity> replyToCommand(@PathVariable("deviceToken") String deviceToken,
+ @PathVariable("requestId") Integer requestId,
+ @RequestBody String json, HttpServletRequest request) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<ResponseEntity>();
+ 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;
+ }
+
+ @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.POST)
+ public DeferredResult<ResponseEntity> postRpcRequest(@PathVariable("deviceToken") String deviceToken,
+ @RequestBody String json, HttpServletRequest httpRequest) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<ResponseEntity>();
+ 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;
+ }
+
+ @RequestMapping(value = "/{deviceToken}/attributes/updates", method = RequestMethod.GET, produces = "application/json")
+ public DeferredResult<ResponseEntity> subscribeToAttributes(@PathVariable("deviceToken") String deviceToken,
+ @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout,
+ HttpServletRequest httpRequest) {
+ DeferredResult<ResponseEntity> responseWriter = new DeferredResult<>();
+ 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));
+
+ }));
+ return responseWriter;
+ }
+
+ private static class DeviceAuthCallback implements TransportServiceCallback<ValidateDeviceCredentialsResponseMsg> {
+ private final TransportContext transportContext;
+ private final DeferredResult<ResponseEntity> responseWriter;
+ private final Consumer<SessionInfoProto> onSuccess;
+
+ DeviceAuthCallback(TransportContext transportContext, DeferredResult<ResponseEntity> responseWriter, Consumer<SessionInfoProto> 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<Void> {
+ 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 HttpOkCallback implements TransportServiceCallback<Void> {
+ private final DeferredResult<ResponseEntity> responseWriter;
+
+ public HttpOkCallback(DeferredResult<ResponseEntity> 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 DeferredResult<ResponseEntity> responseWriter;
+
+ HttpSessionListener(DeferredResult<ResponseEntity> responseWriter) {
+ 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) {
+ responseWriter.setResult(new ResponseEntity<>(HttpStatus.REQUEST_TIMEOUT));
+ }
+
+ @Override
+ public void onToDeviceRpcRequest(ToDeviceRpcRequestMsg msg) {
+ responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK));
+ }
+
+ @Override
+ public void onToServerRpcResponse(ToServerRpcResponseMsg msg) {
+ responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK));
+ }
+ }
+}
common/transport/mqtt/pom.xml 98(+98 -0)
diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml
new file mode 100644
index 0000000..91f30a6
--- /dev/null
+++ b/common/transport/mqtt/pom.xml
@@ -0,0 +1,98 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.common</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>mqtt</artifactId>
+ <packaging>jar</packaging>
+
+ <name>Thingsboard MQTT Transport Common</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>io.netty</groupId>
+ <artifactId>netty-all</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context-support</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>log4j-over-slf4j</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.code.findbugs</groupId>
+ <artifactId>jsr305</artifactId>
+ <version>3.0.1</version>
+ <optional>true</optional>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+</project>
diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java
new file mode 100644
index 0000000..8393ca9
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java
@@ -0,0 +1,217 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+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.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+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.MqttDeviceAwareSessionContext;
+
+import java.nio.charset.Charset;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * @author Andrew Shvayka
+ */
+@Component("JsonMqttAdaptor")
+@Slf4j
+public class JsonMqttAdaptor implements MqttTransportAdaptor {
+
+ private static final Gson GSON = new Gson();
+ private static final Charset UTF8 = Charset.forName("UTF-8");
+ private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false);
+
+ @Override
+ 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));
+ } catch (IllegalStateException | JsonSyntaxException ex) {
+ throw new AdaptorException(ex);
+ }
+ }
+
+ @Override
+ 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));
+ } catch (IllegalStateException | JsonSyntaxException ex) {
+ throw new AdaptorException(ex);
+ }
+ }
+
+ @Override
+ public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext 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<String> clientKeys = toStringSet(requestBody, "clientKeys");
+ Set<String> 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 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()));
+ 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(MqttDeviceAwareSessionContext 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<MqttMessage> convertToPublish(MqttDeviceAwareSessionContext 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 Optional<MqttMessage> 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<MqttMessage> convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException {
+ return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, JsonConverter.toJson(notificationMsg)));
+ }
+
+ @Override
+ public Optional<MqttMessage> 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 Optional<MqttMessage> 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<MqttMessage> convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException {
+ return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_RPC_TOPIC, JsonConverter.toGatewayJson(deviceName, rpcRequest)));
+ }
+
+ @Override
+ public Optional<MqttMessage> 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) {
+ MqttFixedHeader mqttFixedHeader =
+ 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));
+ return new MqttPublishMessage(mqttFixedHeader, header, payload);
+ }
+
+ private Set<String> toStringSet(JsonElement requestBody, String name) {
+ JsonElement element = requestBody.getAsJsonObject().get(name);
+ if (element != null) {
+ return new HashSet<>(Arrays.asList(element.getAsString().split(",")));
+ } else {
+ return null;
+ }
+ }
+
+ public static JsonElement validateJsonPayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException {
+ String payload = validatePayload(sessionId, payloadData);
+ try {
+ return new JsonParser().parse(payload);
+ } catch (JsonSyntaxException ex) {
+ throw new AdaptorException(ex);
+ }
+ }
+
+ private static String validatePayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException {
+ try {
+ String payload = payloadData.toString(UTF8);
+ if (payload == null) {
+ log.warn("[{}] Payload is empty!", sessionId);
+ throw new AdaptorException(new IllegalArgumentException("Payload is empty!"));
+ }
+ return payload;
+ } finally {
+ payloadData.release();
+ }
+ }
+
+}
diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java
new file mode 100644
index 0000000..7af0e66
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java
@@ -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.
+ */
+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.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.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 {
+
+ PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException;
+
+ PostAttributeMsg convertToPostAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException;
+
+ GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException;
+
+ ToDeviceRpcResponseMsg convertToDeviceRpcResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException;
+
+ ToServerRpcRequestMsg convertToServerRpcRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException;
+
+ Optional<MqttMessage> convertToPublish(MqttDeviceAwareSessionContext ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException;
+
+ Optional<MqttMessage> convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException;
+
+ Optional<MqttMessage> convertToPublish(MqttDeviceAwareSessionContext ctx, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException;
+
+ Optional<MqttMessage> convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException;
+
+ Optional<MqttMessage> convertToPublish(MqttDeviceAwareSessionContext ctx, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException;
+
+ Optional<MqttMessage> convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException;
+
+ Optional<MqttMessage> convertToPublish(MqttDeviceAwareSessionContext ctx, ToServerRpcResponseMsg rpcResponse) throws AdaptorException;
+}
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
new file mode 100644
index 0000000..184ac49
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.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.transport.mqtt;
+
+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;
+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 org.thingsboard.server.common.transport.TransportService;
+import org.thingsboard.server.kafka.TbNodeIdProvider;
+import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Created by ashvayka on 04.10.18.
+ */
+@Slf4j
+@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.mqtt.enabled}'=='true')")
+@Component
+public class MqttTransportContext extends TransportContext {
+
+ @Getter
+ @Autowired(required = false)
+ private MqttSslHandlerProvider sslHandlerProvider;
+
+ @Getter
+ @Autowired
+ private MqttTransportAdaptor adaptor;
+
+ @Getter
+ @Value("${transport.mqtt.netty.max_payload_size}")
+ 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
new file mode 100644
index 0000000..a178bdf
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java
@@ -0,0 +1,545 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.JsonNode;
+import io.netty.channel.ChannelHandlerContext;
+import io.netty.channel.ChannelInboundHandlerAdapter;
+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;
+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.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.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.GatewaySessionHandler;
+import org.thingsboard.server.transport.mqtt.session.MqttTopicMatcher;
+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.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+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;
+
+/**
+ * @author Andrew Shvayka
+ */
+@Slf4j
+public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener<Future<? super Void>>, SessionMsgListener {
+
+ private static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE;
+
+ private final UUID sessionId;
+ private final MqttTransportContext context;
+ private final MqttTransportAdaptor adaptor;
+ private final TransportService transportService;
+ private final SslHandler sslHandler;
+ private final ConcurrentMap<MqttTopicMatcher, Integer> mqttQoSMap;
+
+ private volatile SessionInfoProto sessionInfo;
+ private volatile InetSocketAddress address;
+ private volatile DeviceSessionCtx deviceSessionCtx;
+ private volatile GatewaySessionHandler gatewaySessionHandler;
+
+ MqttTransportHandler(MqttTransportContext context) {
+ this.sessionId = UUID.randomUUID();
+ this.context = context;
+ this.transportService = context.getTransportService();
+ this.adaptor = context.getAdaptor();
+ this.sslHandler = context.getSslHandler();
+ this.mqttQoSMap = new ConcurrentHashMap<>();
+ this.deviceSessionCtx = new DeviceSessionCtx(sessionId, mqttQoSMap);
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ log.trace("[{}] Processing msg: {}", sessionId, msg);
+ if (msg instanceof MqttMessage) {
+ processMqttMsg(ctx, (MqttMessage) msg);
+ } else {
+ ctx.close();
+ }
+ }
+
+ private void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) {
+ address = (InetSocketAddress) ctx.channel().remoteAddress();
+ if (msg.fixedHeader() == null) {
+ log.info("[{}:{}] Invalid message received", address.getHostName(), address.getPort());
+ processDisconnect(ctx);
+ return;
+ }
+ deviceSessionCtx.setChannel(ctx);
+ switch (msg.fixedHeader().messageType()) {
+ 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)));
+ transportService.reportActivity(sessionInfo);
+ if (gatewaySessionHandler != null) {
+ gatewaySessionHandler.reportActivity();
+ }
+ }
+ 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().packetId();
+ log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId);
+
+ if (topicName.startsWith(MqttTopics.BASE_GATEWAY_API_TOPIC)) {
+ if (gatewaySessionHandler != null) {
+ handleGatewayPublishMsg(topicName, msgId, mqttMsg);
+ }
+ } else {
+ processDevicePublish(ctx, mqttMsg, topicName, msgId);
+ }
+ }
+
+ private void handleGatewayPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) {
+ try {
+ switch (topicName) {
+ case MqttTopics.GATEWAY_TELEMETRY_TOPIC:
+ gatewaySessionHandler.onDeviceTelemetry(mqttMsg);
+ break;
+ case MqttTopics.GATEWAY_ATTRIBUTES_TOPIC:
+ gatewaySessionHandler.onDeviceAttributes(mqttMsg);
+ break;
+ case MqttTopics.GATEWAY_ATTRIBUTES_REQUEST_TOPIC:
+ gatewaySessionHandler.onDeviceAttributesRequest(mqttMsg);
+ break;
+ case MqttTopics.GATEWAY_RPC_TOPIC:
+ gatewaySessionHandler.onDeviceRpcResponse(mqttMsg);
+ break;
+ case MqttTopics.GATEWAY_CONNECT_TOPIC:
+ gatewaySessionHandler.onDeviceConnect(mqttMsg);
+ break;
+ case MqttTopics.GATEWAY_DISCONNECT_TOPIC:
+ gatewaySessionHandler.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)) {
+ 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));
+ } 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();
+ }
+ }
+
+ private <T> TransportServiceCallback<Void> getPubAckCallback(final ChannelHandlerContext ctx, final int msgId, final T msg) {
+ return new TransportServiceCallback<Void>() {
+ @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);
+ processDisconnect(ctx);
+ }
+ };
+ }
+
+ private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) {
+ if (!checkConnected(ctx)) {
+ return;
+ }
+ log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId());
+ List<Integer> 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: {
+ 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:
+ 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<Integer> grantedQoSList, MqttQoS reqQoS) {
+ grantedQoSList.add(getMinSupportedQos(reqQoS));
+ mqttQoSMap.put(new MqttTopicMatcher(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(new MqttTopicMatcher(topicName));
+ try {
+ switch (topicName) {
+ 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);
+ }
+ }
+ 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());
+ X509Certificate cert;
+ if (sslHandler != null && (cert = getX509Certificate()) != null) {
+ processX509CertConnect(ctx, cert);
+ } else {
+ processAuthTokenConnect(ctx, msg);
+ }
+ }
+
+ private void processAuthTokenConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) {
+ String userName = msg.payload().userName();
+ if (StringUtils.isEmpty(userName)) {
+ ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD));
+ ctx.close();
+ } else {
+ transportService.process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(userName).build(),
+ new TransportServiceCallback<ValidateDeviceCredentialsResponseMsg>() {
+ @Override
+ public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) {
+ onValidateDeviceResponse(msg, ctx);
+ }
+
+ @Override
+ public void onError(Throwable e) {
+ log.trace("[{}] Failed to process credentials: {}", address, userName, e);
+ ctx.writeAndFlush(createMqttConnAckMsg(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE));
+ ctx.close();
+ }
+ });
+ }
+ }
+
+ private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert) {
+ try {
+ String strCert = SslUtil.getX509CertificateString(cert);
+ String sha3Hash = EncryptionUtil.getSha3Hash(strCert);
+ transportService.process(ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(),
+ new TransportServiceCallback<ValidateDeviceCredentialsResponseMsg>() {
+ @Override
+ public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) {
+ onValidateDeviceResponse(msg, ctx);
+ }
+
+ @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() {
+ try {
+ X509Certificate[] certChain = sslHandler.engine().getSession().getPeerCertificateChain();
+ if (certChain.length > 0) {
+ return certChain[0];
+ }
+ } catch (SSLPeerUnverifiedException e) {
+ log.warn(e.getMessage());
+ return null;
+ }
+ return null;
+ }
+
+ private void processDisconnect(ChannelHandlerContext ctx) {
+ ctx.close();
+ if (deviceSessionCtx.isConnected()) {
+ transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.CLOSED), null);
+ transportService.deregisterSession(sessionInfo);
+ if (gatewaySessionHandler != null) {
+ gatewaySessionHandler.onGatewayDisconnect();
+ }
+ }
+ }
+
+ private MqttConnAckMessage createMqttConnAckMsg(MqttConnectReturnCode returnCode) {
+ MqttFixedHeader mqttFixedHeader =
+ new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0);
+ MqttConnAckVariableHeader mqttConnAckVariableHeader =
+ new MqttConnAckVariableHeader(returnCode, true);
+ return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader);
+ }
+
+ @Override
+ public void channelReadComplete(ChannelHandlerContext ctx) {
+ ctx.flush();
+ }
+
+ @Override
+ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
+ log.error("[{}] Unexpected Exception", sessionId, cause);
+ ctx.close();
+ }
+
+ private static MqttSubAckMessage createSubAckMessage(Integer msgId, List<Integer> grantedQoSList) {
+ MqttFixedHeader mqttFixedHeader =
+ new MqttFixedHeader(SUBACK, false, AT_LEAST_ONCE, false, 0);
+ MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId);
+ MqttSubAckPayload mqttSubAckPayload = new MqttSubAckPayload(grantedQoSList);
+ return new MqttSubAckMessage(mqttFixedHeader, mqttMessageIdVariableHeader, mqttSubAckPayload);
+ }
+
+ private static int getMinSupportedQos(MqttQoS reqQoS) {
+ return Math.min(reqQoS.value(), MAX_SUPPORTED_QOS_LVL.value());
+ }
+
+ public static MqttPubAckMessage createMqttPubAckMsg(int requestId) {
+ MqttFixedHeader mqttFixedHeader =
+ new MqttFixedHeader(PUBACK, false, AT_LEAST_ONCE, false, 0);
+ MqttMessageIdVariableHeader mqttMsgIdVariableHeader =
+ MqttMessageIdVariableHeader.from(requestId);
+ return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader);
+ }
+
+ private boolean checkConnected(ChannelHandlerContext ctx) {
+ if (deviceSessionCtx.isConnected()) {
+ return true;
+ } else {
+ log.info("[{}] Closing current session due to invalid msg order [{}][{}]", sessionId);
+ ctx.close();
+ return false;
+ }
+ }
+
+ private void checkGatewaySession() {
+ 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()) {
+ gatewaySessionHandler = new GatewaySessionHandler(context, deviceSessionCtx, sessionId);
+ }
+ }
+ } catch (IOException e) {
+ log.trace("[{}][{}] Failed to fetch device additional info", sessionId, device.getDeviceName(), e);
+ }
+ }
+
+ @Override
+ public void operationComplete(Future<? super Void> future) throws Exception {
+ if (deviceSessionCtx.isConnected()) {
+ transportService.process(sessionInfo, AbstractTransportService.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 {
+ 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, AbstractTransportService.getSessionEventMsg(SessionEvent.OPEN), null);
+ transportService.registerAsyncSession(sessionInfo, this);
+ checkGatewaySession();
+ ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED));
+ }
+ }
+
+ @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 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);
+ }
+ }
+
+ @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/common/transport/mqtt/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
new file mode 100644
index 0000000..fb05e0a
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.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.mqtt.session;
+
+import io.netty.channel.ChannelHandlerContext;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.UUID;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * @author Andrew Shvayka
+ */
+@Slf4j
+public class DeviceSessionCtx extends MqttDeviceAwareSessionContext {
+
+ @Getter
+ private ChannelHandlerContext channel;
+ private AtomicInteger msgIdSeq = new AtomicInteger(0);
+
+ public DeviceSessionCtx(UUID sessionId, ConcurrentMap<MqttTopicMatcher, Integer> mqttQoSMap) {
+ super(sessionId, mqttQoSMap);
+ }
+
+ public void setChannel(ChannelHandlerContext channel) {
+ this.channel = channel;
+ }
+
+ public int nextMsgId() {
+ return msgIdSeq.incrementAndGet();
+ }
+}
diff --git a/common/transport/mqtt/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
new file mode 100644
index 0000000..38c1fff
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java
@@ -0,0 +1,101 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 lombok.extern.slf4j.Slf4j;
+import org.thingsboard.server.common.transport.SessionMsgListener;
+import org.thingsboard.server.gen.transport.TransportProtos;
+import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto;
+import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto;
+
+import java.util.UUID;
+import java.util.concurrent.ConcurrentMap;
+
+/**
+ * Created by ashvayka on 19.01.17.
+ */
+@Slf4j
+public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext implements SessionMsgListener {
+
+ private final GatewaySessionHandler parent;
+ private final SessionInfoProto sessionInfo;
+
+ public GatewayDeviceSessionCtx(GatewaySessionHandler parent, DeviceInfoProto deviceInfo, ConcurrentMap<MqttTopicMatcher, Integer> mqttQoSMap) {
+ super(UUID.randomUUID(), mqttQoSMap);
+ this.parent = parent;
+ 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 UUID getSessionId() {
+ return sessionId;
+ }
+
+ @Override
+ public int nextMsgId() {
+ return parent.nextMsgId();
+ }
+
+ 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 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) {
+ // This feature is not supported in the TB IoT Gateway yet.
+ }
+}
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
new file mode 100644
index 0000000..37fb84b
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java
@@ -0,0 +1,375 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+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.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;
+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 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;
+
+/**
+ * Created by ashvayka on 19.01.17.
+ */
+@Slf4j
+public class GatewaySessionHandler {
+
+ private static final String DEFAULT_DEVICE_TYPE = "default";
+ 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<String, GatewayDeviceSessionCtx> devices;
+ private final ConcurrentMap<MqttTopicMatcher, Integer> mqttQoSMap;
+ private final ChannelHandlerContext channel;
+ private final DeviceSessionCtx deviceSessionCtx;
+
+ public GatewaySessionHandler(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<>();
+ 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);
+ log.trace("[{}] onDeviceConnect: {}", sessionId, deviceName);
+ Futures.addCallback(onDeviceConnect(deviceName, deviceType), new FutureCallback<GatewayDeviceSessionCtx>() {
+ @Override
+ public void onSuccess(@Nullable GatewayDeviceSessionCtx result) {
+ ack(msg);
+ log.trace("[{}] onDeviceConnectOk: {}", sessionId, deviceName);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ log.warn("[{}] Failed to process device connect command: {}", sessionId, deviceName, t);
+
+ }
+ }, context.getExecutor());
+ }
+
+ private ListenableFuture<GatewayDeviceSessionCtx> onDeviceConnect(String deviceName, String deviceType) {
+ SettableFuture<GatewayDeviceSessionCtx> 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<GetOrCreateDeviceFromGatewayResponseMsg>() {
+ @Override
+ public void onSuccess(GetOrCreateDeviceFromGatewayResponseMsg msg) {
+ 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);
+ }
+ 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)));
+ 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(this::deregisterSession);
+ }
+
+ public void onDeviceTelemetry(MqttPublishMessage mqttMsg) throws AdaptorException {
+ JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload());
+ int msgId = mqttMsg.variableHeader().packetId();
+ if (json.isJsonObject()) {
+ JsonObject jsonObj = json.getAsJsonObject();
+ for (Map.Entry<String, JsonElement> deviceEntry : jsonObj.entrySet()) {
+ String deviceName = deviceEntry.getKey();
+ Futures.addCallback(checkDeviceConnected(deviceName),
+ new FutureCallback<GatewayDeviceSessionCtx>() {
+ @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));
+ }
+
+ @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 = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload());
+ int msgId = mqttMsg.variableHeader().packetId();
+ if (json.isJsonObject()) {
+ JsonObject jsonObj = json.getAsJsonObject();
+ for (Map.Entry<String, JsonElement> deviceEntry : jsonObj.entrySet()) {
+ String deviceName = deviceEntry.getKey();
+ Futures.addCallback(checkDeviceConnected(deviceName),
+ new FutureCallback<GatewayDeviceSessionCtx>() {
+ @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 attributes 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<GatewayDeviceSessionCtx>() {
+ @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 = 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<String> 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);
+ }
+ TransportProtos.GetAttributeRequestMsg requestMsg = result.build();
+ int msgId = msg.variableHeader().packetId();
+ Futures.addCallback(checkDeviceConnected(deviceName),
+ new FutureCallback<GatewayDeviceSessionCtx>() {
+ @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 attributes request command: {}", sessionId, deviceName, t);
+ }
+ }, context.getExecutor());
+ ack(msg);
+ } else {
+ throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json);
+ }
+ }
+
+ private ListenableFuture<GatewayDeviceSessionCtx> 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) {
+ 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(sessionId, mqttMsg.payload());
+ }
+
+ private void ack(MqttPublishMessage msg) {
+ if (msg.variableHeader().packetId() > 0) {
+ writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().packetId()));
+ }
+ }
+
+ void writeAndFlush(MqttMessage mqttMessage) {
+ channel.writeAndFlush(mqttMessage);
+ }
+
+ public String getNodeId() {
+ return context.getNodeId();
+ }
+
+ private void deregisterSession(String deviceName, GatewayDeviceSessionCtx deviceSessionCtx) {
+ transportService.deregisterSession(deviceSessionCtx.getSessionInfo());
+ transportService.process(deviceSessionCtx.getSessionInfo(), AbstractTransportService.getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null);
+ log.debug("[{}] Removed device [{}] from the gateway session", sessionId, deviceName);
+ }
+
+ private <T> TransportServiceCallback<Void> getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) {
+ return new TransportServiceCallback<Void>() {
+ @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();
+ }
+ };
+ }
+
+ public MqttTransportContext getContext() {
+ return context;
+ }
+
+ MqttTransportAdaptor getAdaptor() {
+ return context.getAdaptor();
+ }
+
+ int nextMsgId() {
+ return deviceSessionCtx.nextMsgId();
+ }
+
+ public void reportActivity() {
+ devices.forEach((id, deviceCtx) -> transportService.reportActivity(deviceCtx.getSessionInfo()));
+ }
+}
diff --git a/common/transport/mqtt/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
new file mode 100644
index 0000000..745b0ed
--- /dev/null
+++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.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.transport.mqtt.session;
+
+import io.netty.handler.codec.mqtt.MqttQoS;
+import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext;
+
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+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<MqttTopicMatcher, Integer> mqttQoSMap;
+
+ public MqttDeviceAwareSessionContext(UUID sessionId, ConcurrentMap<MqttTopicMatcher, Integer> mqttQoSMap) {
+ super(sessionId);
+ this.mqttQoSMap = mqttQoSMap;
+ }
+
+ public ConcurrentMap<MqttTopicMatcher, Integer> getMqttQoSMap() {
+ return mqttQoSMap;
+ }
+
+ public MqttQoS getQoSForTopic(String topic) {
+ List<Integer> 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;
+ }
+ }
+
+}
common/transport/pom.xml 67(+10 -57)
diff --git a/common/transport/pom.xml b/common/transport/pom.xml
index e132ffb..83e7ba8 100644
--- a/common/transport/pom.xml
+++ b/common/transport/pom.xml
@@ -16,76 +16,29 @@
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
- xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>common</artifactId>
</parent>
<groupId>org.thingsboard.common</groupId>
<artifactId>transport</artifactId>
- <packaging>jar</packaging>
+ <packaging>pom</packaging>
- <name>Thingsboard Server Common Transport components</name>
+ <name>Thingsboard Server Commons</name>
<url>https://thingsboard.io</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.dir>${basedir}/../..</main.dir>
</properties>
-
- <dependencies>
- <dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>data</artifactId>
- </dependency>
- <dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>message</artifactId>
- </dependency>
- <dependency>
- <groupId>com.google.code.gson</groupId>
- <artifactId>gson</artifactId>
- </dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>slf4j-api</artifactId>
- </dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>log4j-over-slf4j</artifactId>
- </dependency>
- <dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-core</artifactId>
- </dependency>
- <dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
- </dependency>
- <dependency>
- <groupId>junit</groupId>
- <artifactId>junit</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.mockito</groupId>
- <artifactId>mockito-all</artifactId>
- <scope>test</scope>
- </dependency>
- <dependency>
- <groupId>org.springframework</groupId>
- <artifactId>spring-context</artifactId>
- </dependency>
- <dependency>
- <groupId>com.google.guava</groupId>
- <artifactId>guava</artifactId>
- </dependency>
- <dependency>
- <groupId>org.apache.commons</groupId>
- <artifactId>commons-lang3</artifactId>
- </dependency>
- </dependencies>
+ <modules>
+ <module>transport-api</module>
+ <module>mqtt</module>
+ <module>http</module>
+ <module>coap</module>
+ </modules>
</project>
common/transport/transport-api/pom.xml 117(+117 -0)
diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml
new file mode 100644
index 0000000..4ed6ed7
--- /dev/null
+++ b/common/transport/transport-api/pom.xml
@@ -0,0 +1,117 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.common</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
+ <packaging>jar</packaging>
+
+ <name>Thingsboard Server Common Transport components</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>data</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>message</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>slf4j-api</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.slf4j</groupId>
+ <artifactId>log4j-over-slf4j</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-core</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-context</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.protobuf</groupId>
+ <artifactId>protobuf-java</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.github.vladimir-bukhtoyarov</groupId>
+ <artifactId>bucket4j-core</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.xolstice.maven.plugins</groupId>
+ <artifactId>protobuf-maven-plugin</artifactId>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
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
new file mode 100644
index 0000000..d52a896
--- /dev/null
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java
@@ -0,0 +1,459 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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.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;
+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.kv.AttributesKVMsg;
+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.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;
+
+public class JsonConverter {
+
+ private static final Gson GSON = new Gson();
+ 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();
+ 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<KeyValueProto> keyValueList = parseProtoValues(jsonObject.getAsJsonObject());
+ result.addAllKv(keyValueList);
+ return result.build();
+ } else {
+ throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject);
+ }
+ }
+
+ 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();
+ 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());
+ }
+
+ 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());
+ }
+
+ private static List<KeyValueProto> parseProtoValues(JsonObject valuesObject) {
+ List<KeyValueProto> result = new ArrayList<>();
+ for (Entry<String, JsonElement> valueEntry : valuesObject.entrySet()) {
+ JsonElement element = valueEntry.getValue();
+ if (element.isJsonPrimitive()) {
+ JsonPrimitive value = element.getAsJsonPrimitive();
+ if (value.isString()) {
+ if(isTypeCastEnabled && NumberUtils.isParsable(value.getAsString())) {
+ try {
+ result.add(buildNumericKeyValueProto(value, valueEntry.getKey()));
+ } catch (RuntimeException 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()) {
+ result.add(buildNumericKeyValueProto(value, valueEntry.getKey()));
+ } else {
+ throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value);
+ }
+ } else {
+ throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element);
+ }
+ }
+ 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();
+ }
+
+ private static void parseNumericValue(List<KvEntry> result, Entry<String, JsonElement> 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!");
+ }
+ }
+ }
+
+ 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 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) {
+ if (!payload.getClientAttributes().isEmpty()) {
+ JsonObject attrObject = new JsonObject();
+ payload.getClientAttributes().forEach(addToObject(attrObject));
+ result.add("client", attrObject);
+ }
+ if (!payload.getSharedAttributes().isEmpty()) {
+ JsonObject attrObject = new JsonObject();
+ payload.getSharedAttributes().forEach(addToObject(attrObject));
+ result.add("shared", attrObject);
+ }
+ } else {
+ payload.getClientAttributes().forEach(addToObject(result));
+ payload.getSharedAttributes().forEach(addToObject(result));
+ }
+ if (!payload.getDeletedAttributes().isEmpty()) {
+ JsonArray attrObject = new JsonArray();
+ payload.getDeletedAttributes().forEach(addToObject(attrObject));
+ result.add("deleted", attrObject);
+ }
+ 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<TransportProtos.TsKvProto> 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<AttributeKey> addToObject(JsonArray result) {
+ return key -> result.add(key.getAttributeKey());
+ }
+
+ private static Consumer<TsKvProto> 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);
+ };
+ }
+
+ private static Consumer<AttributeKvEntry> addToObject(JsonObject result) {
+ return de -> {
+ JsonPrimitive value;
+ switch (de.getDataType()) {
+ case BOOLEAN:
+ value = new JsonPrimitive(de.getBooleanValue().get());
+ break;
+ case DOUBLE:
+ value = new JsonPrimitive(de.getDoubleValue().get());
+ break;
+ case LONG:
+ value = new JsonPrimitive(de.getLongValue().get());
+ break;
+ case STRING:
+ value = new JsonPrimitive(de.getStrValue().get());
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported data type: " + de.getDataType());
+ }
+ result.add(de.getKey(), value);
+ };
+ }
+
+ 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) {
+ JsonObject error = new JsonObject();
+ error.addProperty("error", errorMsg);
+ 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;
+ }
+
+ public static Set<AttributeKvEntry> convertToAttributes(JsonElement element) {
+ Set<AttributeKvEntry> 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<KvEntry> parseValues(JsonObject valuesObject) {
+ List<KvEntry> result = new ArrayList<>();
+ for (Entry<String, JsonElement> valueEntry : valuesObject.entrySet()) {
+ JsonElement element = valueEntry.getValue();
+ if (element.isJsonPrimitive()) {
+ JsonPrimitive value = element.getAsJsonPrimitive();
+ if (value.isString()) {
+ if(isTypeCastEnabled && NumberUtils.isParsable(value.getAsString())) {
+ try {
+ parseNumericValue(result, valueEntry, value);
+ } catch (RuntimeException 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()) {
+ 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<Long, List<KvEntry>> convertToTelemetry(JsonElement jsonObject, long systemTs) throws JsonSyntaxException {
+ Map<Long, List<KvEntry>> 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<Long, List<KvEntry>> 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<Long, List<KvEntry>> result, long systemTs, JsonObject jo) {
+ for (KvEntry entry : parseValues(jo)) {
+ result.computeIfAbsent(systemTs, tmp -> new ArrayList<>()).add(entry);
+ }
+ }
+
+ public static void parseWithTs(Map<Long, List<KvEntry>> 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);
+ }
+ }
+
+ public static void setTypeCastEnabled(boolean enabled) {
+ isTypeCastEnabled = enabled;
+ }
+}
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 0000000..fad1954
--- /dev/null
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java
@@ -0,0 +1,290 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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;
+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;
+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 {
+
+ @Value("${transport.rate_limits.enabled}")
+ private boolean rateLimitEnabled;
+ @Value("${transport.rate_limits.tenant}")
+ 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<UUID, SessionMetaData> sessions = new ConcurrentHashMap<>();
+
+ //TODO: Implement cleanup of this maps.
+ private ConcurrentMap<TenantId, TbTransportRateLimits> perTenantLimits = new ConcurrentHashMap<>();
+ private ConcurrentMap<DeviceId, TbTransportRateLimits> perDeviceLimits = new ConcurrentHashMap<>();
+
+ @Override
+ public void registerAsyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener) {
+ sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, TransportProtos.SessionType.ASYNC, listener));
+ }
+
+ @Override
+ public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback<Void> callback) {
+ if (checkLimits(sessionInfo, callback)) {
+ reportActivityInternal(sessionInfo);
+ doProcess(sessionInfo, msg, callback);
+ }
+ }
+
+ @Override
+ public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback<Void> callback) {
+ if (checkLimits(sessionInfo, callback)) {
+ reportActivityInternal(sessionInfo);
+ doProcess(sessionInfo, msg, callback);
+ }
+ }
+
+ @Override
+ public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback<Void> callback) {
+ if (checkLimits(sessionInfo, callback)) {
+ reportActivityInternal(sessionInfo);
+ doProcess(sessionInfo, msg, callback);
+ }
+ }
+
+ @Override
+ public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback<Void> callback) {
+ if (checkLimits(sessionInfo, callback)) {
+ reportActivityInternal(sessionInfo);
+ doProcess(sessionInfo, msg, callback);
+ }
+ }
+
+ @Override
+ public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback<Void> callback) {
+ 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<Void> callback) {
+ 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<Void> callback) {
+ if (checkLimits(sessionInfo, callback)) {
+ reportActivityInternal(sessionInfo);
+ doProcess(sessionInfo, msg, callback);
+ }
+ }
+
+ @Override
+ public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback<Void> callback) {
+ if (checkLimits(sessionInfo, callback)) {
+ reportActivityInternal(sessionInfo);
+ doProcess(sessionInfo, msg, callback);
+ }
+ }
+
+ @Override
+ public void reportActivity(TransportProtos.SessionInfoProto sessionInfo) {
+ reportActivityInternal(sessionInfo);
+ }
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback<Void> callback);
+
+ protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback<Void> callback);
+
+ 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
+ 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));
+ }
+
+ @Override
+ public boolean checkLimits(TransportProtos.SessionInfoProto sessionInfo, TransportServiceCallback<Void> 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);
+ 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);
+ }
+ }
+
+ private 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() {
+ 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<>());
+ this.schedulerExecutor.scheduleAtFixedRate(this::checkInactivityAndReportActivity, sessionReportTimeout, sessionReportTimeout, TimeUnit.MILLISECONDS);
+ }
+
+ public void destroy() {
+ if (rateLimitEnabled) {
+ perTenantLimits.clear();
+ perDeviceLimits.clear();
+ }
+ if (schedulerExecutor != null) {
+ schedulerExecutor.shutdownNow();
+ }
+ if (transportCallbackExecutor != null) {
+ 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
new file mode 100644
index 0000000..4b11bf5
--- /dev/null
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java
@@ -0,0 +1,324 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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;
+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.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;
+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;
+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.*;
+
+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;
+
+/**
+ * Created by ashvayka on 05.10.18.
+ */
+@ConditionalOnExpression("'${transport.type:null}'=='null'")
+@Service
+@Slf4j
+public class RemoteTransportService extends AbstractTransportService {
+
+ @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}")
+ 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 TbNodeIdProvider nodeIdProvider;
+
+ private TbKafkaRequestTemplate<TransportApiRequestMsg, TransportApiResponseMsg> transportApiTemplate;
+ private TBKafkaProducerTemplate<ToRuleEngineMsg> ruleEngineProducer;
+ private TBKafkaConsumerTemplate<ToTransportMsg> mainConsumer;
+
+ private ExecutorService mainConsumerExecutor = Executors.newSingleThreadExecutor();
+
+ private volatile boolean stopped = false;
+
+ @PostConstruct
+ public void init() {
+ super.init();
+
+ TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder<TransportApiRequestMsg> requestBuilder = TBKafkaProducerTemplate.builder();
+ requestBuilder.settings(kafkaSettings);
+ requestBuilder.defaultTopic(transportApiRequestsTopic);
+ requestBuilder.encoder(new TransportApiRequestEncoder());
+
+ TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder<TransportApiResponseMsg> responseBuilder = TBKafkaConsumerTemplate.builder();
+ responseBuilder.settings(kafkaSettings);
+ responseBuilder.topic(transportApiResponsesTopic + "." + nodeIdProvider.getNodeId());
+ responseBuilder.clientId("transport-api-client-" + nodeIdProvider.getNodeId());
+ responseBuilder.groupId("transport-api-client");
+ responseBuilder.autoCommit(true);
+ responseBuilder.autoCommitIntervalMs(autoCommitInterval);
+ responseBuilder.decoder(new TransportApiResponseDecoder());
+
+ TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder
+ <TransportApiRequestMsg, TransportApiResponseMsg> 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();
+
+ TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder<ToRuleEngineMsg> ruleEngineProducerBuilder = TBKafkaProducerTemplate.builder();
+ ruleEngineProducerBuilder.settings(kafkaSettings);
+ ruleEngineProducerBuilder.defaultTopic(ruleEngineTopic);
+ ruleEngineProducerBuilder.encoder(new ToRuleEngineMsgEncoder());
+ 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<ToTransportMsg> mainConsumerBuilder = TBKafkaConsumerTemplate.builder();
+ mainConsumerBuilder.settings(kafkaSettings);
+ mainConsumerBuilder.topic(notificationsTopicName);
+ mainConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId());
+ mainConsumerBuilder.groupId("transport");
+ mainConsumerBuilder.autoCommit(true);
+ mainConsumerBuilder.autoCommitIntervalMs(notificationsAutoCommitInterval);
+ mainConsumerBuilder.decoder(new ToTransportMsgResponseDecoder());
+ mainConsumer = mainConsumerBuilder.build();
+ mainConsumer.subscribe();
+
+ mainConsumerExecutor.execute(() -> {
+ while (!stopped) {
+ try {
+ ConsumerRecords<String, byte[]> records = mainConsumer.poll(Duration.ofMillis(notificationsPollDuration));
+ records.forEach(record -> {
+ try {
+ ToTransportMsg toTransportMsg = mainConsumer.decode(record);
+ if (toTransportMsg.hasToDeviceSessionMsg()) {
+ processToTransportMsg(toTransportMsg.getToDeviceSessionMsg());
+ }
+ } 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() {
+ super.destroy();
+ stopped = true;
+ if (transportApiTemplate != null) {
+ transportApiTemplate.stop();
+ }
+ if (mainConsumer != null) {
+ mainConsumer.unsubscribe();
+ }
+ if (mainConsumerExecutor != null) {
+ mainConsumerExecutor.shutdownNow();
+ }
+ }
+
+ @Override
+ public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback<ValidateDeviceCredentialsResponseMsg> 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<ValidateDeviceCredentialsResponseMsg> callback) {
+ AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getHash(),
+ TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()),
+ response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor);
+ }
+
+ @Override
+ public void process(GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback<GetOrCreateDeviceFromGatewayResponseMsg> callback) {
+ AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getDeviceName(),
+ TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()),
+ response -> callback.onSuccess(response.getGetOrCreateDeviceResponseMsg()), callback::onError, transportCallbackExecutor);
+ }
+
+ @Override
+ public void process(SessionInfoProto sessionInfo, SubscriptionInfoProto msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setSubscriptionInfo(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setSessionEvent(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setPostTelemetry(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setPostAttributes(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setGetAttributes(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setSubscribeToAttributes(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setSubscribeToRPC(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setToDeviceRPCCallResponse(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ @Override
+ protected void doProcess(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback<Void> callback) {
+ ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg(
+ TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo)
+ .setToServerRPCCallRequest(msg).build()
+ ).build();
+ send(sessionInfo, toRuleEngineMsg, callback);
+ }
+
+ private static class TransportCallbackAdaptor implements Callback {
+ private final TransportServiceCallback<Void> callback;
+
+ TransportCallbackAdaptor(TransportServiceCallback<Void> callback) {
+ this.callback = callback;
+ }
+
+ @Override
+ public void onCompletion(RecordMetadata metadata, Exception exception) {
+ if (exception == null) {
+ if (callback != null) {
+ callback.onSuccess(null);
+ }
+ } else {
+ if (callback != null) {
+ callback.onError(exception);
+ }
+ }
+ }
+ }
+
+ private void send(SessionInfoProto sessionInfo, ToRuleEngineMsg toRuleEngineMsg, TransportServiceCallback<Void> callback) {
+ ruleEngineProducer.send(getRoutingKey(sessionInfo), toRuleEngineMsg, new TransportCallbackAdaptor(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
new file mode 100644
index 0000000..8642e93
--- /dev/null
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.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.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
+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/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 0000000..d598734
--- /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/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
new file mode 100644
index 0000000..8944e94
--- /dev/null
+++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java
@@ -0,0 +1,74 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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;
+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;
+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;
+import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg;
+
+/**
+ * Created by ashvayka on 04.10.18.
+ */
+public interface TransportService {
+
+ void process(ValidateDeviceTokenRequestMsg msg,
+ TransportServiceCallback<ValidateDeviceCredentialsResponseMsg> callback);
+
+ void process(ValidateDeviceX509CertRequestMsg msg,
+ TransportServiceCallback<ValidateDeviceCredentialsResponseMsg> callback);
+
+ void process(TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg msg,
+ TransportServiceCallback<TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg> callback);
+
+ boolean checkLimits(SessionInfoProto sessionInfo, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback<Void> callback);
+
+ void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback<Void> 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/proto/transport.proto b/common/transport/transport-api/src/main/proto/transport.proto
new file mode 100644
index 0000000..ff740d4
--- /dev/null
+++ b/common/transport/transport-api/src/main/proto/transport.proto
@@ -0,0 +1,234 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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;
+ int64 tenantIdMSB = 4;
+ int64 tenantIdLSB = 5;
+ int64 deviceIdMSB = 6;
+ int64 deviceIdLSB = 7;
+}
+
+enum SessionEvent {
+ OPEN = 0;
+ 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;
+ KeyValueType type = 2;
+ bool bool_v = 3;
+ int64 long_v = 4;
+ double double_v = 5;
+ string string_v = 6;
+}
+
+message TsKvProto {
+ int64 ts = 1;
+ KeyValueProto kv = 2;
+}
+
+message TsKvListProto {
+ int64 ts = 1;
+ repeated KeyValueProto kv = 2;
+}
+
+message DeviceInfoProto {
+ int64 tenantIdMSB = 1;
+ int64 tenantIdLSB = 2;
+ int64 deviceIdMSB = 3;
+ int64 deviceIdLSB = 4;
+ string deviceName = 5;
+ string deviceType = 6;
+ string additionalInfo = 7;
+}
+
+/**
+ * Messages that use Data Structures;
+ */
+message SessionEventMsg {
+ SessionType sessionType = 1;
+ SessionEvent event = 2;
+}
+
+message PostTelemetryMsg {
+ repeated TsKvListProto tsKvList = 1;
+}
+
+message PostAttributeMsg {
+ repeated KeyValueProto kv = 1;
+}
+
+message GetAttributeRequestMsg {
+ int32 requestId = 1;
+ repeated string clientAttributeNames = 2;
+ repeated string sharedAttributeNames = 3;
+}
+
+message GetAttributeResponseMsg {
+ int32 requestId = 1;
+ repeated TsKvProto clientAttributeList = 2;
+ repeated TsKvProto sharedAttributeList = 3;
+ repeated string deletedAttributeKeys = 4;
+ string error = 5;
+}
+
+message AttributeUpdateNotificationMsg {
+ repeated TsKvProto sharedUpdated = 1;
+ repeated string sharedDeleted = 2;
+}
+
+message ValidateDeviceTokenRequestMsg {
+ string token = 1;
+}
+
+message ValidateDeviceX509CertRequestMsg {
+ string hash = 1;
+}
+
+message ValidateDeviceCredentialsResponseMsg {
+ DeviceInfoProto deviceInfo = 1;
+ string credentialsBody = 2;
+}
+
+message GetOrCreateDeviceFromGatewayRequestMsg {
+ int64 gatewayIdMSB = 1;
+ int64 gatewayIdLSB = 2;
+ string deviceName = 3;
+ string deviceType = 4;
+}
+
+message GetOrCreateDeviceFromGatewayResponseMsg {
+ DeviceInfoProto deviceInfo = 1;
+}
+
+message SessionCloseNotificationProto {
+ string message = 1;
+}
+
+message SubscribeToAttributeUpdatesMsg {
+ bool unsubscribe = 1;
+}
+
+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;
+}
+
+//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;
+ PostTelemetryMsg postTelemetry = 3;
+ PostAttributeMsg postAttributes = 4;
+ GetAttributeRequestMsg getAttributes = 5;
+ SubscribeToAttributeUpdatesMsg subscribeToAttributes = 6;
+ SubscribeToRPCMsg subscribeToRPC = 7;
+ ToDeviceRpcResponseMsg toDeviceRPCCallResponse = 8;
+ ToServerRpcRequestMsg toServerRPCCallRequest = 9;
+ SubscriptionInfoProto subscriptionInfo = 10;
+}
+
+message DeviceActorToTransportMsg {
+ int64 sessionIdMSB = 1;
+ int64 sessionIdLSB = 2;
+ SessionCloseNotificationProto sessionCloseNotification = 3;
+ GetAttributeResponseMsg getAttributesResponse = 4;
+ AttributeUpdateNotificationMsg attributeUpdateNotification = 5;
+ ToDeviceRpcRequestMsg toDeviceRequest = 6;
+ ToServerRpcResponseMsg toServerResponse = 7;
+}
+
+/**
+ * Main messages;
+ */
+message ToRuleEngineMsg {
+ TransportToDeviceActorMsg toDeviceActorMsg = 1;
+}
+
+message ToTransportMsg {
+ DeviceActorToTransportMsg toDeviceSessionMsg = 1;
+}
+
+message TransportApiRequestMsg {
+ ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1;
+ ValidateDeviceX509CertRequestMsg validateX509CertRequestMsg = 2;
+ GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 3;
+}
+
+message TransportApiResponseMsg {
+ ValidateDeviceCredentialsResponseMsg validateTokenResponseMsg = 1;
+ GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 2;
+}
dao/pom.xml 2(+1 -1)
diff --git a/dao/pom.xml b/dao/pom.xml
index b12ea42..275aee0 100644
--- a/dao/pom.xml
+++ b/dao/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>dao</artifactId>
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 6c8b609..ebd0560 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;
@@ -52,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;
@@ -77,6 +80,9 @@ public class BaseAssetService extends AbstractEntityService implements AssetServ
private CustomerDao customerDao;
@Autowired
+ private EntityViewService entityViewService;
+
+ @Autowired
private CacheManager cacheManager;
@Override
@@ -130,11 +136,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<EntityView> 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 (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);
+ }
+
List<Object> 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/cache/CaffeineCacheConfiguration.java b/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java
index 688dfb3..fc2868e 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<Object, Object> 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<? super Object, ? super Object> collectionSafeWeigher() {
+ return (Weigher<Object, Object>) (key, value) -> {
+ if(value instanceof Collection) {
+ return ((Collection) value).size();
+ }
+ return 1;
+ };
+ }
}
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 5675232..19409ad 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 0296807..247a204 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 474cad7..1f09342 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 8171ccc..15263c8 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/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java
index 36b250d..a9b8bfe 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;
@@ -70,6 +71,9 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom
private DeviceService deviceService;
@Autowired
+ private EntityViewService entityViewService;
+
+ @Autowired
private DashboardService dashboardService;
@Override
@@ -111,6 +115,7 @@ 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);
userService.deleteCustomerUsers(customer.getTenantId(), customerId);
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 4219b06..101bff2 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;
@@ -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);
}
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 3930e3a..2be5ba4 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;
@@ -56,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;
@@ -87,6 +90,9 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe
private DeviceCredentialsService deviceCredentialsService;
@Autowired
+ private EntityViewService entityViewService;
+
+ @Autowired
private CacheManager cacheManager;
@Override
@@ -145,18 +151,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<EntityView> 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 (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);
+ }
+
DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(deviceId);
if (deviceCredentials != null) {
deviceCredentialsService.deleteDeviceCredentials(deviceCredentials);
}
deleteEntityRelations(deviceId);
- Device device = deviceDao.findById(deviceId.getId());
+
List<Object> list = new ArrayList<>();
list.add(device.getTenantId());
list.add(device.getName());
+ Cache cache = cacheManager.getCache(DEVICE_CACHE);
cache.evict(list);
+
deviceDao.removeById(deviceId.getId());
}
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 1b67ca0..33a2699 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;
@@ -47,6 +48,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe
private DeviceService deviceService;
@Autowired
+ private EntityViewService entityViewService;
+
+ @Autowired
private TenantService tenantService;
@Autowired
@@ -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/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java
new file mode 100644
index 0000000..6ddc18d
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java
@@ -0,0 +1,185 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+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 javax.annotation.Nullable;
+import java.util.ArrayList;
+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.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_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;
+
+/**
+ * Created by Victor Basanets on 9/06/2017.
+ */
+@Component
+@Slf4j
+@NoSqlDao
+public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao<EntityViewEntity, EntityView> implements EntityViewDao {
+
+ @Override
+ protected Class<EntityViewEntity> 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.getType());
+ EntitySubtypeEntity entitySubtypeEntity = new EntitySubtypeEntity(entitySubtype);
+ Statement saveStatement = cluster.getMapper(EntitySubtypeEntity.class).saveQuery(entitySubtypeEntity);
+ executeWrite(saveStatement);
+ return savedEntityView;
+ }
+
+ @Override
+ public List<EntityView> findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink) {
+ log.debug("Try to find entity views by tenantId [{}] and pageLink [{}]", tenantId, pageLink);
+ List<EntityViewEntity> entityViewEntities =
+ 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<EntityView> findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink) {
+ log.debug("Try to find entity views by tenantId [{}], type [{}] and pageLink [{}]", tenantId, type, pageLink);
+ List<EntityViewEntity> 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<EntityView> 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<EntityView> findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink) {
+ log.debug("Try to find entity views by tenantId [{}], customerId[{}] and pageLink [{}]",
+ tenantId, customerId, pageLink);
+ List<EntityViewEntity> entityViewEntities = findPageWithTextSearch(
+ 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 [{}]",
+ entityViewEntities, tenantId, customerId, pageLink);
+ return DaoUtil.convertDataList(entityViewEntities);
+ }
+
+ @Override
+ public List<EntityView> 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<EntityViewEntity> 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<List<EntityView>> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) {
+ log.debug("Try to find entity views by tenantId [{}] and entityId [{}]", tenantId, entityId);
+ 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));
+ return findListByStatementAsync(query);
+ }
+
+ @Override
+ public ListenableFuture<List<EntitySubtype>> 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<ResultSet, List<EntitySubtype>>() {
+ @Nullable
+ @Override
+ public List<EntitySubtype> apply(@Nullable ResultSet resultSet) {
+ Result<EntitySubtypeEntity> result = cluster.getMapper(EntitySubtypeEntity.class).map(resultSet);
+ if (result != null) {
+ List<EntitySubtype> 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
new file mode 100644
index 0000000..8147a07
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java
@@ -0,0 +1,105 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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;
+
+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<EntityView> {
+
+ /**
+ * 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.
+ *
+ * @param tenantId the tenantId
+ * @param pageLink the page link
+ * @return the list of entity view objects
+ */
+ List<EntityView> 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<EntityView> findEntityViewsByTenantIdAndType(UUID tenantId, String type, 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<EntityView> findEntityViewByTenantIdAndName(UUID tenantId, String name);
+
+ /**
+ * 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<EntityView> 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<EntityView> findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId,
+ UUID customerId,
+ String type,
+ TextPageLink pageLink);
+
+ ListenableFuture<List<EntityView>> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId);
+
+ /**
+ * Find tenants entity view types.
+ *
+ * @return the list of tenant entity view type objects
+ */
+ ListenableFuture<List<EntitySubtype>> 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
new file mode 100644
index 0000000..9c82866
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java
@@ -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.
+ */
+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;
+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 java.util.List;
+
+/**
+ * Created by Victor Basanets on 8/27/2017.
+ */
+public interface EntityViewService {
+
+ EntityView saveEntityView(EntityView entityView);
+
+ EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId);
+
+ EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId);
+
+ void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId);
+
+ EntityView findEntityViewById(EntityViewId entityViewId);
+
+ EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name);
+
+ TextPageData<EntityView> findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink);
+
+ TextPageData<EntityView> findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type);
+
+ TextPageData<EntityView> findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink);
+
+ TextPageData<EntityView> findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, TextPageLink pageLink, String type);
+
+ ListenableFuture<List<EntityView>> findEntityViewsByQuery(EntityViewSearchQuery query);
+
+ ListenableFuture<EntityView> findEntityViewByIdAsync(EntityViewId entityViewId);
+
+ ListenableFuture<List<EntityView>> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId);
+
+ void deleteEntityView(EntityViewId entityViewId);
+
+ void deleteEntityViewsByTenantId(TenantId tenantId);
+
+ ListenableFuture<List<EntitySubtype>> 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
new file mode 100644
index 0000000..9f8949d
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java
@@ -0,0 +1,368 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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.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.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.entityview.EntityViewSearchQuery;
+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;
+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;
+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.Collections;
+import java.util.Comparator;
+import java.util.List;
+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.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;
+
+ @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) {
+ log.trace("Executing save entity view [{}]", entityView);
+ entityViewValidator.validate(entityView);
+ EntityView savedEntityView = entityViewDao.save(entityView);
+ return savedEntityView;
+ }
+
+ @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}")
+ @Override
+ public EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId) {
+ EntityView entityView = findEntityViewById(entityViewId);
+ entityView.setCustomerId(customerId);
+ return saveEntityView(entityView);
+ }
+
+ @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}")
+ @Override
+ public EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId) {
+ EntityView entityView = findEntityViewById(entityViewId);
+ entityView.setCustomerId(null);
+ return saveEntityView(entityView);
+ }
+
+ @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);
+ }
+
+ @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);
+ 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<EntityView> entityViewOpt = entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name);
+ return entityViewOpt.orElse(null);
+ }
+
+ @Override
+ public TextPageData<EntityView> findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink) {
+ log.trace("Executing findEntityViewsByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink);
+ validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
+ validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink);
+ List<EntityView> entityViews = entityViewDao.findEntityViewsByTenantId(tenantId.getId(), pageLink);
+ return new TextPageData<>(entityViews, pageLink);
+ }
+
+ @Override
+ public TextPageData<EntityView> 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<EntityView> entityViews = entityViewDao.findEntityViewsByTenantIdAndType(tenantId.getId(), type, pageLink);
+ return new TextPageData<>(entityViews, pageLink);
+ }
+
+ @Override
+ public TextPageData<EntityView> 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<EntityView> entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(),
+ customerId.getId(), pageLink);
+ return new TextPageData<>(entityViews, pageLink);
+ }
+
+ @Override
+ public TextPageData<EntityView> 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<EntityView> entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId.getId(),
+ customerId.getId(), type, pageLink);
+ return new TextPageData<>(entityViews, pageLink);
+ }
+
+ @Override
+ public ListenableFuture<List<EntityView>> findEntityViewsByQuery(EntityViewSearchQuery query) {
+ ListenableFuture<List<EntityRelation>> relations = relationService.findByQuery(query.toEntitySearchQuery());
+ ListenableFuture<List<EntityView>> entityViews = Futures.transformAsync(relations, r -> {
+ EntitySearchDirection direction = query.toEntitySearchQuery().getParameters().getDirection();
+ List<ListenableFuture<EntityView>> 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);
+ });
+
+ entityViews = Futures.transform(entityViews, new Function<List<EntityView>, List<EntityView>>() {
+ @Nullable
+ @Override
+ public List<EntityView> apply(@Nullable List<EntityView> entityViewList) {
+ return entityViewList == null ? Collections.emptyList() : entityViewList.stream().filter(entityView -> query.getEntityViewTypes().contains(entityView.getType())).collect(Collectors.toList());
+ }
+ });
+
+ return entityViews;
+ }
+
+ @Override
+ public ListenableFuture<EntityView> findEntityViewByIdAsync(EntityViewId entityViewId) {
+ log.trace("Executing findEntityViewById [{}]", entityViewId);
+ validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId);
+ return entityViewDao.findByIdAsync(entityViewId.getId());
+ }
+
+ @Override
+ public ListenableFuture<List<EntityView>> 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<Object> tenantIdAndEntityId = new ArrayList<>();
+ tenantIdAndEntityId.add(tenantId);
+ tenantIdAndEntityId.add(entityId);
+
+ Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE);
+ List<EntityView> fromCache = cache.get(tenantIdAndEntityId, List.class);
+ if (fromCache != null) {
+ return Futures.immediateFuture(fromCache);
+ } else {
+ ListenableFuture<List<EntityView>> entityViewsFuture = entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId());
+ Futures.addCallback(entityViewsFuture,
+ new FutureCallback<List<EntityView>>() {
+ @Override
+ public void onSuccess(@Nullable List<EntityView> 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}")
+ @Override
+ public void deleteEntityView(EntityViewId entityViewId) {
+ log.trace("Executing deleteEntityView [{}]", entityViewId);
+ validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId);
+ 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());
+ }
+
+ @Override
+ public void deleteEntityViewsByTenantId(TenantId tenantId) {
+ log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId);
+ validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
+ tenantEntityViewRemover.removeEntities(tenantId);
+ }
+
+ @Override
+ public ListenableFuture<List<EntitySubtype>> findEntityViewTypesByTenantId(TenantId tenantId) {
+ log.trace("Executing findEntityViewTypesByTenantId, tenantId [{}]", tenantId);
+ validateId(tenantId, INCORRECT_TENANT_ID + tenantId);
+ ListenableFuture<List<EntitySubtype>> tenantEntityViewTypes = entityViewDao.findTenantEntityViewTypesAsync(tenantId.getId());
+ return Futures.transform(tenantEntityViewTypes,
+ entityViewTypes -> {
+ entityViewTypes.sort(Comparator.comparing(EntitySubtype::getType));
+ return entityViewTypes;
+ });
+ }
+
+ private DataValidator<EntityView> entityViewValidator =
+ new DataValidator<EntityView>() {
+
+ @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(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!");
+ }
+ 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<TenantId, EntityView> tenantEntityViewRemover =
+ new PaginatedRemover<TenantId, EntityView>() {
+
+ @Override
+ protected List<EntityView> findEntities(TenantId id, TextPageLink pageLink) {
+ return entityViewDao.findEntityViewsByTenantId(id.getId(), pageLink);
+ }
+
+ @Override
+ protected void removeEntity(EntityView entity) {
+ deleteEntityView(new EntityViewId(entity.getUuidId()));
+ }
+ };
+
+ private class CustomerEntityViewsUnAssigner extends PaginatedRemover<CustomerId, EntityView> {
+
+ private TenantId tenantId;
+
+ CustomerEntityViewsUnAssigner(TenantId tenantId) {
+ this.tenantId = tenantId;
+ }
+
+ @Override
+ protected List<EntityView> findEntities(CustomerId id, TextPageLink pageLink) {
+ return entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), id.getId(), pageLink);
+ }
+
+ @Override
+ protected void removeEntity(EntityView entity) {
+ unassignEntityViewFromCustomer(new EntityViewId(entity.getUuidId()));
+ }
+ }
+}
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 3a934eb..86ba594 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
@@ -52,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.
*/
@@ -130,12 +129,12 @@ 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";
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";
@@ -144,6 +143,26 @@ public class ModelConstants {
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_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_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";
+
+ /**
* Cassandra audit log constants.
*/
public static final String AUDIT_LOG_COLUMN_FAMILY_NAME = "audit_log";
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 0000000..cda4217
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java
@@ -0,0 +1,164 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.Column;
+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 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.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;
+
+import javax.persistence.EnumType;
+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;
+
+/**
+ * Created by Victor Basanets on 8/31/2017.
+ */
+@Data
+@Table(name = ENTITY_VIEW_TABLE_FAMILY_NAME)
+@EqualsAndHashCode
+@ToString
+@Slf4j
+public class EntityViewEntity implements SearchTextEntity<EntityView> {
+
+ @PartitionKey(value = 0)
+ @Column(name = ID_PROPERTY)
+ private UUID id;
+
+ @Enumerated(EnumType.STRING)
+ @Column(name = ENTITY_TYPE_PROPERTY)
+ private EntityType entityType;
+
+ @PartitionKey(value = 1)
+ @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY)
+ private UUID tenantId;
+
+ @PartitionKey(value = 2)
+ @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;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY)
+ private String name;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY)
+ private String keys;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_START_TS_PROPERTY)
+ private long startTs;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_END_TS_PROPERTY)
+ private long endTs;
+
+ @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY)
+ private String searchText;
+
+ @Type(type = "json")
+ @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY)
+ private JsonNode additionalInfo;
+
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ 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();
+ this.entityType = entityView.getEntityId().getEntityType();
+ }
+ if (entityView.getTenantId() != null) {
+ this.tenantId = entityView.getTenantId().getId();
+ }
+ if (entityView.getCustomerId() != null) {
+ this.customerId = entityView.getCustomerId().getId();
+ }
+ this.type = entityView.getType();
+ this.name = entityView.getName();
+ try {
+ this.keys = mapper.writeValueAsString(entityView.getKeys());
+ } catch (IOException e) {
+ log.error("Unable to serialize entity view keys!", e);
+ }
+ this.startTs = entityView.getStartTimeMs();
+ this.endTs = entityView.getEndTimeMs();
+ this.searchText = entityView.getSearchText();
+ 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(EntityIdFactory.getByTypeAndId(entityType.name(), entityId.toString()));
+ }
+ if (tenantId != null) {
+ entityView.setTenantId(new TenantId(tenantId));
+ }
+ if (customerId != null) {
+ entityView.setCustomerId(new CustomerId(customerId));
+ }
+ entityView.setType(type);
+ entityView.setName(name);
+ try {
+ entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class));
+ } catch (IOException e) {
+ log.error("Unable to read entity view keys!", e);
+ }
+ entityView.setStartTimeMs(startTs);
+ entityView.setEndTimeMs(endTs);
+ entityView.setAdditionalInfo(additionalInfo);
+ return entityView;
+ }
+}
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 3001e12..1c9f17a 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 587a314..515c86c 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<AttributeKvEntry>, 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<AttributeKvEntry>, 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/model/sql/ComponentDescriptorEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java
index 66fbcc3..e4c6bb5 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<ComponentDescriptor
@Column(name = ModelConstants.COMPONENT_DESCRIPTOR_NAME_PROPERTY)
private String name;
- @Column(name = ModelConstants.COMPONENT_DESCRIPTOR_CLASS_PROPERTY)
+ @Column(name = ModelConstants.COMPONENT_DESCRIPTOR_CLASS_PROPERTY, unique = true)
private String clazz;
@Type(type = "json")
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 0000000..6999c0c
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/EntityViewEntity.java
@@ -0,0 +1,163 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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.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.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;
+
+/**
+ * Created by Victor Basanets on 8/30/2017.
+ */
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Entity
+@TypeDef(name = "json", typeClass = JsonStringType.class)
+@Table(name = ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME)
+@Slf4j
+public class EntityViewEntity extends BaseSqlEntity<EntityView> implements SearchTextEntity<EntityView> {
+
+ @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;
+
+ @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;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY)
+ private String keys;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_START_TS_PROPERTY)
+ private long startTs;
+
+ @Column(name = ModelConstants.ENTITY_VIEW_END_TS_PROPERTY)
+ private long endTs;
+
+ @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY)
+ private String searchText;
+
+ @Type(type = "json")
+ @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY)
+ private JsonNode additionalInfo;
+
+ private static final ObjectMapper mapper = new ObjectMapper();
+
+ 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());
+ this.entityType = entityView.getEntityId().getEntityType();
+ }
+ if (entityView.getTenantId() != null) {
+ this.tenantId = toString(entityView.getTenantId().getId());
+ }
+ 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());
+ } catch (IOException e) {
+ log.error("Unable to serialize entity view keys!", e);
+ }
+ this.startTs = entityView.getStartTimeMs();
+ this.endTs = entityView.getEndTimeMs();
+ this.searchText = entityView.getSearchText();
+ 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()));
+
+ if (entityId != null) {
+ entityView.setEntityId(EntityIdFactory.getByTypeAndId(entityType.name(), toUUID(entityId).toString()));
+ }
+ if (tenantId != null) {
+ entityView.setTenantId(new TenantId(toUUID(tenantId)));
+ }
+ if (customerId != null) {
+ entityView.setCustomerId(new CustomerId(toUUID(customerId)));
+ }
+ entityView.setType(type);
+ entityView.setName(name);
+ try {
+ entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class));
+ } catch (IOException e) {
+ log.error("Unable to read entity view keys!", e);
+ }
+ entityView.setStartTimeMs(startTs);
+ entityView.setEndTimeMs(endTs);
+ entityView.setAdditionalInfo(additionalInfo);
+ return entityView;
+ }
+}
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 d1af167..b38110b 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<String, PreparedStatement> 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 0000000..a3490bf
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java
@@ -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.
+ */
+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.util.AbstractBufferedRateExecutor;
+import org.thingsboard.server.dao.util.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<CassandraStatementTask, ResultSetFuture, ResultSet> {
+
+ 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 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());
+ }
+
+ @PreDestroy
+ public void stop() {
+ super.stop();
+ }
+
+ @Override
+ protected SettableFuture<ResultSet> create() {
+ return SettableFuture.create();
+ }
+
+ @Override
+ protected ResultSetFuture wrap(CassandraStatementTask task, SettableFuture<ResultSet> future) {
+ return new TbResultSetFuture(future);
+ }
+
+ @Override
+ protected ResultSetFuture execute(AsyncTaskContext<CassandraStatementTask, ResultSet> taskCtx) {
+ CassandraStatementTask task = taskCtx.getTask();
+ return task.getSession().executeAsync(task.getStatement());
+ }
+
+}
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 0000000..574a5f5
--- /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<ResultSet> mainFuture;
+
+ public TbResultSetFuture(SettableFuture<ResultSet> 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/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java
index c76cefe..d716886 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<AttributeKvEntity, AttributeKvCompositeKey> {
- List<AttributeKvEntity> 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<AttributeKvEntity> 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 0dabf4c..4ac0c0c 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<Void> 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());
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 0000000..efd1bd9
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java
@@ -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.
+ */
+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.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<EntityViewEntity, String> {
+
+ @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<EntityViewEntity> 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.type = :type " +
+ "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " +
+ "AND e.id > :idOffset ORDER BY e.id")
+ List<EntityViewEntity> 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<EntityViewEntity> 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.type = :type " +
+ "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " +
+ "AND e.id > :idOffset ORDER BY e.id")
+ List<EntityViewEntity> 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<EntityViewEntity> findAllByTenantIdAndEntityId(String tenantId, String entityId);
+
+ @Query("SELECT DISTINCT ev.type FROM EntityViewEntity ev WHERE ev.tenantId = :tenantId")
+ List<String> 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
new file mode 100644
index 0000000..1ba9b9e
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java
@@ -0,0 +1,141 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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.UUIDConverter;
+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;
+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;
+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<EntityViewEntity, EntityView>
+ implements EntityViewDao {
+
+ @Autowired
+ private EntityViewRepository entityViewRepository;
+
+ @Override
+ protected Class<EntityViewEntity> getEntityClass() {
+ return EntityViewEntity.class;
+ }
+
+ @Override
+ protected CrudRepository<EntityViewEntity, String> getCrudRepository() {
+ return entityViewRepository;
+ }
+
+ @Override
+ public List<EntityView> findEntityViewsByTenantId(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 List<EntityView> 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<EntityView> findEntityViewByTenantIdAndName(UUID tenantId, String name) {
+ return Optional.ofNullable(
+ DaoUtil.getData(entityViewRepository.findByTenantIdAndName(fromTimeUUID(tenantId), name)));
+ }
+
+ @Override
+ public List<EntityView> findEntityViewsByTenantIdAndCustomerId(UUID tenantId,
+ UUID customerId,
+ TextPageLink pageLink) {
+ return DaoUtil.convertDataList(
+ entityViewRepository.findByTenantIdAndCustomerId(
+ fromTimeUUID(tenantId),
+ fromTimeUUID(customerId),
+ Objects.toString(pageLink.getTextSearch(), ""),
+ pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()),
+ new PageRequest(0, pageLink.getLimit())
+ ));
+ }
+
+ @Override
+ public List<EntityView> 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<List<EntityView>> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) {
+ return service.submit(() -> DaoUtil.convertDataList(
+ entityViewRepository.findAllByTenantIdAndEntityId(UUIDConverter.fromTimeUUID(tenantId), UUIDConverter.fromTimeUUID(entityId))));
+ }
+
+ @Override
+ public ListenableFuture<List<EntitySubtype>> findTenantEntityViewTypesAsync(UUID tenantId) {
+ return service.submit(() -> convertTenantEntityViewTypesToDto(tenantId, entityViewRepository.findTenantEntityViewTypes(fromTimeUUID(tenantId))));
+ }
+
+ private List<EntitySubtype> convertTenantEntityViewTypesToDto(UUID tenantId, List<String> types) {
+ List<EntitySubtype> 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/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java
index 1eb2f00..04227a3 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;
@@ -26,10 +27,12 @@ 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;
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;
@@ -40,9 +43,10 @@ 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;
import javax.annotation.PostConstruct;
@@ -51,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;
@@ -59,9 +64,11 @@ import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID;
@Component
@Slf4j
-@SqlDao
+@SqlTsDao
public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService implements TimeseriesDao {
+ private static final String DESC_ORDER = "DESC";
+
@Value("${sql.ts_inserts_executor_type}")
private String insertExecutorType;
@@ -238,7 +245,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
@@ -322,14 +331,72 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp
@Override
public ListenableFuture<Void> 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<TsKvEntry> latestFuture = findLatest(entityId, query.getKey());
+
+ ListenableFuture<Boolean> booleanFuture = Futures.transform(latestFuture, tsKvEntry -> {
+ long ts = tsKvEntry.getTs();
+ return ts > query.getStartTs() && ts <= query.getEndTs();
+ }, service);
+
+ ListenableFuture<Void> 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<Void> resultFuture = new SimpleListenableFuture<>();
+ Futures.addCallback(removedLatestFuture, new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(@Nullable Void result) {
+ if (query.getRewriteLatestIfDeleted()) {
+ ListenableFuture<Void> 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<Void> 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<List<TsKvEntry>> 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 2b39d25..296d173 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<TsKvEntity, TsKvComposite
@Query("SELECT tskv FROM TsKvEntity tskv WHERE tskv.entityId = :entityId " +
"AND tskv.entityType = :entityType AND tskv.key = :entityKey " +
- "AND tskv.ts > :startTs AND tskv.ts < :endTs ORDER BY tskv.ts DESC")
+ "AND tskv.ts > :startTs AND tskv.ts < :endTs")
List<TsKvEntity> findAllWithLimit(@Param("entityId") String entityId,
@Param("entityType") EntityType entityType,
@Param("entityKey") String key,
@@ -47,7 +47,7 @@ public interface TsKvRepository extends CrudRepository<TsKvEntity, TsKvComposite
@Modifying
@Query("DELETE FROM TsKvEntity tskv WHERE tskv.entityId = :entityId " +
"AND tskv.entityType = :entityType AND tskv.key = :entityKey " +
- "AND tskv.ts > :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/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java
index d92c941..189c713 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;
@@ -64,6 +65,9 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe
private DeviceService deviceService;
@Autowired
+ private EntityViewService entityViewService;
+
+ @Autowired
private WidgetsBundleService widgetsBundleService;
@Autowired
@@ -101,6 +105,7 @@ 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);
userService.deleteTenantAdmins(tenantId);
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 98e859f..1006499 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,16 +20,25 @@ 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;
import org.thingsboard.server.common.data.kv.TsKvEntry;
+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;
+import java.util.stream.Collectors;
import static org.apache.commons.lang3.StringUtils.isBlank;
@@ -40,16 +49,30 @@ 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;
+ @Autowired
+ private EntityViewService entityViewService;
+
@Override
public ListenableFuture<List<TsKvEntry>> findAll(EntityId entityId, List<ReadTsKvQuery> 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<ReadTsKvQuery> filteredQueries =
+ queries.stream()
+ .filter(query -> entityView.getKeys().getTimeseries().isEmpty() || entityView.getKeys().getTimeseries().contains(query.getKey()))
+ .collect(Collectors.toList());
+ return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, filteredQueries));
+ }
return timeseriesDao.findAllAsync(entityId, queries);
}
@@ -58,6 +81,27 @@ public class BaseTimeseriesService implements TimeseriesService {
validate(entityId);
List<ListenableFuture<TsKvEntry>> futures = Lists.newArrayListWithExpectedSize(keys.size());
keys.forEach(key -> Validator.validateString(key, "Incorrect key " + key));
+ if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) {
+ EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId);
+ List<String> filteredKeys = new ArrayList<>(keys);
+ if (entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null &&
+ !entityView.getKeys().getTimeseries().isEmpty()) {
+ filteredKeys.retainAll(entityView.getKeys().getTimeseries());
+ }
+ List<ReadTsKvQuery> queries =
+ filteredKeys.stream()
+ .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) {
+ 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);
}
@@ -65,7 +109,17 @@ public class BaseTimeseriesService implements TimeseriesService {
@Override
public ListenableFuture<List<TsKvEntry>> 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
@@ -92,11 +146,33 @@ public class BaseTimeseriesService implements TimeseriesService {
}
private void saveAndRegisterFutures(List<ListenableFuture<Void>> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) {
+ 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));
futures.add(timeseriesDao.save(entityId, tsKvEntry, ttl));
}
+ private List<ReadTsKvQuery> updateQueriesForEntityView(EntityView entityView, List<ReadTsKvQuery> queries) {
+ return queries.stream().map(query -> {
+ long startTs;
+ if (entityView.getStartTimeMs() != 0 && entityView.getStartTimeMs() > query.getStartTs()) {
+ startTs = entityView.getStartTimeMs();
+ } else {
+ startTs = query.getStartTs();
+ }
+
+ 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());
+ }
+
@Override
public ListenableFuture<List<Void>> remove(EntityId entityId, List<DeleteTsKvQuery> deleteTsKvQueries) {
validate(entityId);
@@ -118,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())) {
@@ -126,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) {
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 03d569d..fdc69f9 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,7 @@ 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;
import javax.annotation.PostConstruct;
@@ -61,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;
@@ -70,7 +71,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;
@@ -433,14 +434,14 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem
public ListenableFuture<Void> removeLatest(EntityId entityId, DeleteTsKvQuery query) {
ListenableFuture<TsKvEntry> latestEntryFuture = findLatest(entityId, query.getKey());
- ListenableFuture<Boolean> booleanFuture = Futures.transformAsync(latestEntryFuture, latestEntry -> {
+ ListenableFuture<Boolean> 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<Void> removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
@@ -450,18 +451,34 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem
return Futures.immediateFuture(null);
}, readResultsProcessingExecutor);
- if (query.getRewriteLatestIfDeleted()) {
- ListenableFuture<Void> savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> {
- if (isRemove) {
- return getNewLatestEntryFuture(entityId, query);
+ final SimpleListenableFuture<Void> resultFuture = new SimpleListenableFuture<>();
+ Futures.addCallback(removedLatestFuture, new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(@Nullable Void result) {
+ if (query.getRewriteLatestIfDeleted()) {
+ ListenableFuture<Void> 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<Void> getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) {
diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java
new file mode 100644
index 0000000..96d3870
--- /dev/null
+++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java
@@ -0,0 +1,175 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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<T extends AsyncTask, F extends ListenableFuture<V>, V> implements BufferedRateExecutor<T, F> {
+
+ private final long maxWaitTime;
+ private final long pollMs;
+ private final BlockingQueue<AsyncTaskContext<T, V>> 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<V> 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<V> create();
+
+ protected abstract F wrap(T task, SettableFuture<V> future);
+
+ protected abstract ListenableFuture<V> execute(AsyncTaskContext<T, V> taskCtx);
+
+ private void dispatch() {
+ log.info("Buffered rate executor thread started");
+ while (!Thread.interrupted()) {
+ int curLvl = concurrencyLevel.get();
+ AsyncTaskContext<T, V> taskCtx = null;
+ try {
+ if (curLvl <= concurrencyLimit) {
+ taskCtx = queue.take();
+ final AsyncTaskContext<T, V> finalTaskCtx = taskCtx;
+ logTask("Processing", finalTaskCtx);
+ concurrencyLevel.incrementAndGet();
+ long timeout = finalTaskCtx.getCreateTime() + maxWaitTime - System.currentTimeMillis();
+ if (timeout > 0) {
+ totalLaunched.incrementAndGet();
+ ListenableFuture<V> result = execute(finalTaskCtx);
+ result = Futures.withTimeout(result, timeout, TimeUnit.MILLISECONDS, timeoutExecutor);
+ Futures.addCallback(result, new FutureCallback<V>() {
+ @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 (InterruptedException e) {
+ break;
+ } 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<T, V> taskCtx) {
+ if (log.isTraceEnabled()) {
+ log.trace("[{}] {} task: {}", taskCtx.getId(), action, taskCtx);
+ } else {
+ log.debug("[{}] {} task", taskCtx.getId(), action);
+ }
+ }
+
+ protected int getQueueSize() {
+ return queue.size();
+ }
+}
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 96dbdab..c3a719d 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 3986f02..ab39c84 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-ts.cql b/dao/src/main/resources/cassandra/schema-ts.cql
new file mode 100644
index 0000000..a5c5ec2
--- /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' };
dao/src/main/resources/cassandra/system-data.cql 240(+1 -239)
diff --git a/dao/src/main/resources/cassandra/system-data.cql b/dao/src/main/resources/cassandra/system-data.cql
index 16a53ca..3a0a9fd 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":"<canvas id=\"lineChart\"></canvas>\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<div class=\"gpio-panel\" style=\"height: 100%;\">\n <section layout=\"row\" ng-repeat=\"row in rows\">\n <section flex layout=\"row\" ng-repeat=\"cell in row\">\n <section layout=\"row\" flex ng-if=\"cell\" layout-align=\"{{$index===0 ? ''end center'' : ''start center''}}\">\n <span class=\"gpio-left-label\" ng-show=\"$index===0\">{{ cell.label }}</span>\n <section layout=\"row\" class=\"led-panel\" ng-class=\"$index===0 ? ''col-0'' : ''col-1''\"\n ng-style=\"{backgroundColor: ledPanelBackgroundColor }\">\n <span class=\"pin\" ng-show=\"$index===0\">{{cell.pin}}</span>\n <span class=\"led-container\">\n <tb-led-light size=\"prefferedRowHeight\"\n color-on=\"cell.colorOn\"\n color-off=\"cell.colorOff\"\n off-opacity=\"''0.9''\"\n tb-enabled=\"cell.enabled\">\n </tb-led-light>\n </span>\n <span class=\"pin\" ng-show=\"$index===1\">{{cell.pin}}</span>\n </section>\n <span class=\"gpio-right-label\" ng-show=\"$index===1\">{{ cell.label }}</span>\n </section>\n <section layout=\"row\" flex ng-if=\"!cell\">\n <span flex ng-show=\"$index===0\"></span>\n <span class=\"led-panel\"\n ng-style=\"{backgroundColor: ledPanelBackgroundColor }\"></span>\n <span flex ng-show=\"$index===1\"></span>\n </section>\n </section>\n </section> \n</div>","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":"<tb-timeseries-table-widget \n config=\"config\"\n table-id=\"tableId\"\n datasources=\"datasources\"\n data=\"data\">\n</tb-timeseries-table-widget>","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":"<canvas id=\"pieChart\"></canvas>\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\": \"<div class=''card''>HTML code here</div>\"\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\":\"<div class=''card''>HTML code here</div>\",\"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":"<canvas id=\"digitalGauge\"></canvas>","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":"<canvas id=\"radialGauge\"></canvas>\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<canvas id=\"pieChart\"></canvas>\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":"<canvas id=\"pieChart\"></canvas>\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":"<canvas id=\"barChart\"></canvas>\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<canvas id=\"digitalGauge\"></canvas>","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 ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${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\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>Speed:</b> ${Speed} MPH<br/><small>See advanced settings for details</small>\",\"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 = $(''<div/>'');\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 = $(''<img />'');\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 ''${#<key index>} 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 \"<div id=''\" + datasourceId +\n \"'' class=''tbDatasource-container''></div>\"\n );\n \n self.ctx.datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n \n var tableId = ''table'' + 0;\n self.ctx.datasourceContainer.append(\n \"<table id=''\" + tableId +\n \"'' class=''tbDatasource-table''><col width=''30%''><col width=''70%''></table>\"\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 \"<tr><td class=''tbDatasource-data-key'' id=''\" + labelCellId +\"''>\" +\n dataKey.label +\n \"</td><td class=''tbDatasource-value'' id=''\" +\n cellId +\n \"''></td></tr>\");\n } else {\n table.append(\n \"<tr style=''vertical-align: bottom;''><td class=''tbDatasource-data-key'' id=''\" + labelCellId +\"''>\" +\n dataKey.label +\n \"</td></tr><tr><td class=''tbDatasource-value'' id=''\" +\n cellId +\n \"''></td></tr>\");\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 = ''<span>'' + html_org + ''</span>'';\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<canvas id=\"digitalGauge\"></canvas>","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\": \"<div class=''card''>HTML code here</div>\"\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\":\"<div class=''card''>\\n <div class=''content''>\\n <div class=''column''>\\n <h1>Value title</h1>\\n <div class=''value''>\\n ${My value:2} units.\\n </div> \\n <div class=''description''>\\n Value description text\\n </div>\\n </div>\\n <img height=\\\"80px\\\" src=\\\"https://thingsboard.io/images/logo_small.png\\\" />\\n </div>\\n</div>\"},\"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":"<div class=\"gpio-panel\" style=\"height: 100%;\">\n <section layout=\"row\" ng-repeat=\"row in rows\">\n <section flex layout=\"row\" ng-repeat=\"cell in row\">\n <section layout=\"row\" flex ng-if=\"cell\" layout-align=\"{{$index===0 ? ''end center'' : ''start center''}}\">\n <span class=\"gpio-left-label\" ng-show=\"$index===0\">{{ cell.label }}</span>\n <section layout=\"row\" class=\"led-panel\" ng-class=\"$index===0 ? ''col-0'' : ''col-1''\"\n ng-style=\"{backgroundColor: ledPanelBackgroundColor}\">\n <span class=\"pin\" ng-show=\"$index===0\">{{cell.pin}}</span>\n <span class=\"led-container\">\n <tb-led-light size=\"prefferedRowHeight\"\n color-on=\"cell.colorOn\"\n color-off=\"cell.colorOff\"\n off-opacity=\"''0.9''\"\n tb-enabled=\"cell.enabled\">\n </tb-led-light>\n </span>\n <span class=\"pin\" ng-show=\"$index===1\">{{cell.pin}}</span>\n </section>\n <span class=\"gpio-right-label\" ng-show=\"$index===1\">{{ cell.label }}</span>\n </section>\n <section layout=\"row\" flex ng-if=\"!cell\">\n <span flex ng-show=\"$index===0\"></span>\n <span class=\"led-panel\"\n ng-style=\"{backgroundColor: ledPanelBackgroundColor}\"></span>\n <span flex ng-show=\"$index===1\"></span>\n </section>\n </section>\n </section> \n</div>","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":"<canvas id=\"radarChart\"></canvas>\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<canvas id=\"digitalGauge\"></canvas>\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 ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${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\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>Speed:</b> ${Speed} MPH<br/><small>See advanced settings for details</small>\",\"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,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\",\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAABdCAYAAAAyj+FzAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAH3gAAB94BHQKrYQAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAACAASURBVHic3bx5uF1Flff/WVV77zPeIQMJIYQxYRRBpBGcQFEEbVQQUXB6xW5tWx9+Cm07IYIitiJog2P7qu3UCN22aDs0KIIyg0CYyUhCyHiTmzucce+qtd4/zrkhQIIogz6/9Tz1nHP22buG715D1VpVS/gLktnZjg3P2wGz3RC/N9hBCHtjMgOxGjDZv3UAkyZim4EHQO4i2j3UkxXUb9kkcrb+pcYgz3aDNvLjOah7BSZvRnwLX/8D2axILM0Dtx9ODgGGt/P4GNgtqD6A764i3+iJ44dC9CD/jaRXyqzXrHs2x/OsAGhrL9sB8W/B7ARKwz/H7TAE2btAZj89DbAW6X4HHRknnzgW7KdU5fsyeMKmp6X+J6BnDEAzhLWXHYz4c3GlG8l2HgL3frDsmWqzR5KDfpnOykkIL8D4OHPecIcI9oy09kxUaut//CKCfp7qtMuwwb8DnrOd5nOcXIfJCryAOgEpASUwh5HiBMwKEAW6YF1chIghthtqL97uSxG5C8a/TXvsBLx9VGa/8Yane6xPK4C2/kd7EuWblIZ+itU+BMx93E1OFmLchqQpTmaDA0kAlyDSwSwizkAFfN84RAfOQASLCVACDVgAESOGDZjmSDwEtYO20bVVaPMCionjiPoe2eXNy56uMT8tAJp9I2X10GfxyQTJ4GtADn3UDd6NYv5niKtgyQxcYjinkCSYi3gHikeSLhDwXjHzTNlWB4hEYnRAgoUSmIIqxAQsYEFQA/JRNHZw+lqiTn90R/VGYuNKQl5j/cTH5JD3FE917E8ZQFv23b0oJf9GNnQd8PFH1y7rkORKLJ2BTxJcAqQOSQyXGEiC+IB4wZxHXMABeIeZQ3q/MBQRhdiDNGqCaMSiYTFBKIiF64FYKBbAigKLD6PFsYjt+uhOcwHdiUMRe5fMe8uSpzL+pwSgrfje+/CyK1n1dcBeW/7wMoZll4Kfhy95SARXMsSDSxxkIInifIK4gHpH6kAlggjOOwTBJEFV8FIQDcQCGIh6ighOFdMELQKYYF1Bg0KgB2ZuxG4kFqvw+cmoDG7V/cXk7Z9iulx2efvX/1wM/iwA7bLLPIe1v4m4RSTJuWDJI/+675OUB3ClCmSC9yA1cKn1dF3mSFLDRJEsAYk9wJxSTEzQGW3TXlEQC6G7sde/0gzDZ0Zlt5Ty9Arp4GBfnBWCgxghOkIhkBsxGK4QYgs0gHZAQxPtNLH41q2GH4jhNFRfwrzK20ROis84gLbkohLp4C/IuAXso1v+UFmLlK4gLc/Bl4AMfEWQDHzZIAFJhKQMLgWzhMayjTz0kyYbrt2T2Nq3a5WFE3HWqgYzNxeU81ao5ADVpJ2ldLI6G6YN+o3zStI+CF+9n1kvWcYub6hR330mIgEtIHSAYFgOVkDREegYmoO2QfOHCJ3X4thqDirn0rUX0Kr9rex/Uv6MAWhLLiqRDPwPafdBxN79SC3yS1y9S5JVoQpSEnylB5wrgSvTAzCt0Vqzmfs/32By8cs2xZ2vXGJHjo/KnlVz1aEsLZsXKVQkpIlXTHo6T8wFjU5iTELULFpEQmN8Gsub87lq2gy/5pUM7ftb9jtjgNJO07DQQHPBerMeYqsnztaGmBvabhNbVYivemRwfJWitADktbL7OztPO4C25KISvnolWWMN8OZHasj+L5Luih9QfAWSmvVEtwquAi4DyWay6aYHuO/8A1phYOkd9sbVTbdgVlYqJVk5wycpWZaRJY7E+2i44L2Y0Zv8CkgMCMQ0xOCKGAndnCJG8m5ueacba7pkw/Pksp2rvrkH+3/kXqY9fx+cbiA0HbEDdIzYBmvRE+12l7y1GRffsRUj/JBubR6xdbQsOK37tAFol13mOXjzzymNrQL+/pGnS1/FZXvg64IfAKkqSTnBVRRXEaQ8g7HFK7jnU/NHde7tC5N3RpKB4WqpKuVq2cpZSpJlkvrMSqVEvE8ty9JClSJJJIr44BwWY0xjNC9iWZ4HH2OUdrsjzpm1Wi3X6RTW7XZpdNviwsToQfodP92tPpj9z1nG8Pxd0M4mYtugEygaGdpWQgO05aCxFI3/+Mhg5WsUQ3vx0Npj5GVnh6cHwCVfv4x0ZAViH9py0WcXIpUDcPVIOiBIFZIauKrgKg6z2Sw8c0m72XK3uA+MuMq06dVqzSqlTGq1uqVp6iuVsqRpGtIsM++ceO8NEQQi0AGmuKAElAEfYxTAQowUeZBu0U3zTkc7nVzzvGOtVpdGqyHa3rTpUPvSjpVyreDgc/dGZB3aVrRlFE3QlmBtoxhXtPUQlr/nEVTceRQzd5c9/+GUpwygLf7Ke0jHykjrS1suavpV0vqeuDqkdUEGlKTsSQYUqcxmcvky7v38c+6yt/xqvHTwvEp90OqVTKrVitVqNcrVasiyTBLnUhEBGAXW9ssqYD2QA1MT3bQP4g7APGBOv0w3M4kxxm5RxG67nUxOTkq7ndPO29YYb9pQ55a1z3U/Opb9P3wXA7vtgbbXow1HbBk6aYSGoA2hmFgK+ggnxoEPYENR5r/3y382gHb/xQeQFm/Gr/0ImOuD9zXS6p4kg4ofBKkJyYD1gCzty9IfXlVsumuXm7NPrC6Xh6sDA1XJyhWmDQ1ampa0XM689z4FNgP3A0uBdr8vSk/veXpceFj/9y39/6ccAq7/vQLsCewHDMUYQ7fbtWaz6VqtXJutCWt3OtJubBo7rPjMXsmMA5cz/60vJ3TuJzQEm1TiREJsRsKEElqrcEWfE0UJcz4P2Q9kwfvv/ZMBtD98I6XW+QXZkj0Q9uzdLZcgA8OkdYdMF5KakgwK1AWfPof7Lr52vJk27qufVq0PDKTltOoGBqo2OFi3crmszrkB4CHgAXpc5vpgSf/7MFAD9gVe0AcHYAlwK3Af0AQm+gDrVmV2H8i5ZtZpt9s2MdGwRqNJq9WUVqsZ9m1f1BqqxAr7v++laH43sWHEhhAaRhjzMBkJExNgJ/XhWUK+YDVx9FWy/9nbnN4k27oIQK39FUqLFmLhlX1beB+U67jMIRlIGiF1kApen8PCzy0cYZ91Dw2/dadp9ZqrVWtFrVZLa7UyWZaVRGQc+D2wiR73zARKqhqccwqIquKcmzSz14rImUC135uWmZ0nIrf0fw+oqjjnhJ54d4AGPU6dJSL7VqvVoSRJOlmWSKmU+KxSssVjH6zOa/5g8453nn8bzz3tEHzpDrTrECeQKnjBJSmhdQ+izwEWkNx/Oez9ReB924Jpmxxo91+wl0rjjc4vO7d3wQKu8kP84CyyIUHqgh82/ICQVA7k3m9evybstXb98FtnD9Xrrj5Qp5RlWq/Xnfd+AFgILANSVfVAHZjVf4E1EZlhZs8VEWdm3xCRD/QBfqSjIhtU9SIR+XszUxG5T0Q2quokPV25EZjsv4wA7AI838wajUaDZrujk+NjyWSzyZzJ/3pwTnrffPb5u79B2wsJEylxUonjkTAhxPENaOcURBIADfM/6bT+H7L/6Uv/KIBmiN1z/tWS3TqESM81JMlXSQb3QIYgGwQ/CH5QoDaflT+7cXNjYGLl4Lt3qA/W3WC9rtVqxdfr9QxIVPVeYKNzzoCOqjpVLSdJUg0huCRJusC4qp5FT7wPAlYCP1XVkf5zs4DXAbsCtwN7OOfO7nNiRk+EWzHGwntvzrlSn0N3APYzszjZbE50Wu1sfGKcZqNtu05+ffO0aitlj+NeSphcTGyATkCcgDhmhInlWJziutst/E1D9v2nIx/rmH08gPd+7ijSpc/BRnpW1+RWXGUd6WCGH+5xXjJo+MFhRpcu6q6+a2jJzHO65UpdBgeqbmBgwMrlciYiqqqLnXOFqk7vc4WPMRpQTZJkB1U9FEhF5Fwzu9DMbnfO/VBEXqyqrwGmHKW5c+4XZnatqr5dRA7y3p+uqh8DVFVvE5H1QAtwIhLo6dSNzrkKsEeMMQ0h5GNjY9rpFH5iYizutemTrjTvuS0G91iANcYJE544HoljQpyMxOZcsAN7SM3+AHHPhbLvP/9ua7zc47jP7OPEhz+KdcA64MIdSJKBBxFFpPcGigmx1Tc/f9G0syZrtZofHKi5en3AyuVy0gfveufc5qIo6mZWAWaZ2Rzvfdl7/3AI4SozGzCztqqeG0L4ELCPql4QYzzezFRVH1DVB8xMY4zHq+qFwHwzOyOEcF6Msamqg865K2KMK0XE05vaTDOzUgihqqojqnqD9z5kWZYMDAwmpVJGvT4gS6af1baHbzyY0FQwD67nzBVngMNz4xYcbOXHLMRzzEy2CyB3nfdicQ/8FOvMRrsQu78A2xWJYCaY6zGwxf1Y+quwbNrp19QHB6u1Wo16vUalUk5EZGdVXaiqQ3meO+/9BhFZrqqr6OnAE83sH/ttV4HvAB3n3Plmttg5d6Zz7sNm9i0zu69fvqWqH3bOnWlmy83sAhFpiMh/0JtgO+/9e1X1TTHGATNbb2YPOefGVbUEDKvqQmBOqZS5wcE6lXpdqgODlWXTP/Iblv48RePeOOt5wrUvpZrvDt3/7WMxS9zi/+aezx2+NWSPssImeqbY4j23XJDSeszvBAJODFHBWcrIA1c1sr1zN7DHjqVKhUqlQqlUcsA8M3tQVV8MNEVkjqoGEVkLPKCqVwEvA3Iz+yDwG+BMEfmtmV0hIifHGM81e3T8Z+p3jBHgTu/9h4qiOM4591ERuTKEcIb0JCMTkSuAGWa2v3Nujpk5MxsFEhFZ7ZybWyqVHq6pOrQaJuPOcxvNve6sjy2+l+Gdd8EkIAJ9LFHWYFMLokXvN9vzQWCLE2ILgPbA53bS7qrfi3WP7l+6GvxcJPRG4Kwn5LE1l/FVu63e4fM31CsV6pWKZVlm3vshVb0S2BBjTAG890PAAar6ahE5EjjPzOohhLO89xeaWQ6caWZnAS/vA3Wlqv5eRFqPAbEmIkeIyCtCCAeKSEdEPqaqfw/MVdXTReRCEXHAe4B6COEqEbk7y7LNIQRCCEWaprO894eWsmyTxuhDCG7NzH8s77X+jBcyML2AsBIxQ0xwKgTdCcl/h9kRwAJh/fds4blz5aAzVz+aA7vtE527fi7WXz87/wCwO+ZAVIgFuODZuOqmkfrrJirV+k5ZkkiWZWRZtqOZbYgxHglUnXOJiKwMIdyRZdnVIYTXmdl6EXm7qq52zn1BRD6gqv8CnAtc65z7sarua2avF5GjeQyJCH3R/IaZLXLOvSHGeB498f+AmV2oqqu89/8nxjgmIrPSNL0qhDAjhPBiM9sdiCGEpvd+JE3TOTHGDZVyOSpUNtVe/fMZI9cNMn32LliMmBnmIs4ghnshHtHrybU7Iq9/A3DRFgDNEL1l/U6uUhzRN8wbUD+vF8PAepecoHEandburbkvv7mSpZTLFSuXywnQCSFc1xezIefcc83sOOCQoig+18fgy2Z2gZl92cyON7MvAb9wzl2vqqfHGKfW2rmqLnTOPaiqK1XVJUkyD9iD3grlPX0wNwIfU9WXmNmXzGyVmf1cRN7rnDtdVS+MMTpVfTcwA/gVsFBExsyscM4dVy6XnRmxiGqTw6/cYcbqKw9nmo6CbEREURFIDPW7IGETyAwIL9PO+oZZ7516gLOP/budTZfuIXp3FT89Q9yvcaUhpNTzKLuy4TJlcmLZWPqi+2P9wIFqtUq5XDLv/d5mdqeqntLXQaNm9gBwrIisMbN5fZ10sHPuJ8C7gbu9919X1XeZ2dH0ph8Xq+p/A8tFZJr1RObFIvICM9vVzB4Efq6ql5rZXSLyfOBvRWRXVf2EiMxxzh3vnPt2jPEdgJjZcF+kZznnvuGcK6vqwar6ehG5XUSeZ6YbBcOMxIrx20v5is2UXA0NPYesRgfqIf4Bs5l0H/yDWHUZq45eec63/jDpALTg1c7dNouox9Nafh1KgKRnrhWPakKINZrtlzSnv7ZWq5UlTb1kWVYG1hZFsczMFpnZ61X1VBFJrfeKvm1mL+nruLkhhKPN7MNmdngI4Twzu8Y59wHgWjP7sIhcaGbvV9WXqGpZVVv9UlXVl6rqaX0996GpZ/v68jwze4GZfTiE8BpgjpmdZWYvCSF818wwszSE8E4zO9HMVqnqMhFZm2VZ4n0qWVay1ow31Gg0DydaBTMH3qHiEGeoy2kuv4YY3wy3zUL1WOjLq133ritxP3w+2HSgQTrwc8p7DeBqDl/ueVxi1o7Nimza5VNFqVqlkmWSpulBqvrrT3/603bTTTed6pwrPVZ3/TWRqnZf/epX3/ye97znZzHGwVKpNEtEXhVCuL3b7dLpdGzmio9XZKBb4PIKoQXSNEKrS3dJh2LytfRiFqMWTvmDe+m3X5WYne30+kUbnFkvCC38L0U+HdZ2KO9uSMxw0Wh3xyanvXHCe79TImLee8ys1O125998881HOec46aSTfrd06dIlCxYsWHDvvfcu2n///fdutVqNPM8LYNqvf/3rI733d5xwwgnjU4MaGRnZuHHjxk2zZ8/eYfr06dMvvfTS/VV12pve9KbrZWrSvhWJCHfeeWdz8eLFr5kxY8YtL3/5y1t9cOyBBx5YMn/+/N2yLMtGR0c3XXHFFUclSTI8MDBw1THHHON/+ctf2hVXXPGyer1+/ymnnHJkURSrsiwrpWlqeZ6Lc07Gh49bOty4ZJB6UcXFnh+gvcbQfAbGlcDrwaabdcfMznYJt66Yha2+A3hLr4tuBGQIbWe0V7Yp7xlJY5mQPjfUD16YJok453DOzVTVmycnJ1/kvXdFUXROOumkF8cYJ0XkkBNPPPEgM/secBpwu4hc8etf//rI4447bvyEE07YDNwAvA04cqt535L/+Z//WTk5OZmedNJJP6PnsgJIY4xVYHfgqGOPPbZ26qmn8sY3vjE/4ogjqsB/xhhf6r1/N4D3/lxVPfG6667b0O12hy+++OJ9yuXywAknnHD9qaeeuujHP/7x604++eQlIvJKVf2ZiMzy3m/IshJh2iE1Ri/dn8I2o/lGOg8NQLeKCuDWYr04l0tX38rNuoOjU+zpdHHSW2EAKiWE0PO2FTW6K0qE8fWmyXBWqdfTNLM0TUVV91fVDVtzhqp+xjl3HHC+mVXN7FQz+5CZHaSqHwVYt27dGjN7jZmdr6r7hBC+r6qnq+q5ZrYsy7LEOSchhAtCCF/ql/PN7BwzO8HM/tBut7/Sr2uVqh5iZuc7514FnA98N8Z4ppnNz/M8AmRZNg04X0SOPeWUU64WkTkrVqy4sq8bVwP7eO/FewdpdQjxMwgTG2g9tAMxr6BqGBGTdAtO4QFH7ua7qLoXrjHvEQBtGmopph6LghZCc023M/yCr5mZgk2tCBpm1th61aCqJ5vZ9WZ2DvAZoGlm58cY/11V/y/ATjvtNBe4S1VPB74FvBG4EDhTVY9xzomqmpn9j6r+SFV/FGP8jZnd1Tcmx1er1dMAhoaGZqvqN/ov4D5V/ZCqvkNVvxljvDTLMm9mOOcuNLNPm9mNr3zlK1+oqvHKK698oZmpmY2LyIRzzkTEvEinW33ORbTXdqEbUQUjwUiINn0LTtaYi9meiWg8ACkO6GPQQaRCtBwfCyIlEEOHO6jf1bmkSJIkAENmtjaE8KrR0dHvAh/pc+FewMMxxt+IyDkicq2q/peIvK//tlm+fPnyGOMxwIV9Sz1mZpc75xYCRbPZfDO9KcjuZlYDMLMNZrZSRH6rqqsnJiYAzldVAd7br2d1COHMNE33VtW/FxFijIv6n2c6537rnFNVPRxYd9ddd80xs/NDCMc659aratk5twFI1dUXEJM2lnchRlwUMI+TKpCjZEjYT0PsJBJ1fxwHA2ByD6KG8wENAWcZBYKUM4b2ayeJK8eolqZJbma5qh5YrVbX98FLb7jhhi8fcsghpyZJsjbG+Enn3Dn9acyXVRURef+ee+65B70A0b/EGDc5514A/G0I4c0A1Wr1rna7japuvadwd9VHtkEPDAxM9EV4tYicEULY0Xv/9yJyboyRGOMX77vvPpfn+SnOObrd7qdKpdJbzGxP4JMzZsw4ft26dTNijEc65w4ErnbOdfO8GEqSxJLpB25ktFzDaUSDoNERDEQdjvsxDgQ7VFRDglgZo95TZPogJlUsRpCIRkEAqQ672ryuiQQRSVV1DzObEJH/k2XZJ/uK21988cXv74vyHqr6MTMbT9O0VSqVjqHn9OTyyy/fddWqVfckSXJ4lmU2PDxMjPFmVV3TbDZHNm3a9Gag+pOf/OSHSZL4iYmJep7n3YmJCdm8eXPsdDph06ZNOwMvufLKK1/5+9///tA8z2tFUQB0RCTz3n8QwLmesynP83enaTrovf+AmV04f/7862666aY9RWSWiLwjxniqiOyRJH5pURQastkxteowRW6g1tsVZgZiRHsIOBBjEI2VBNXe3kUAtSaQoma4QsEJFoQ0rbq0hjmX9yTKhoHRWq32yRUrVnyn2Wx+qlqt0g9R4pyT/pywBAx1u48E+WOMu91yyy27sR1Kkt7y/PLLL3/L9u6ZIhGZ3e12Z2/93LZo2bJl//2c5zxnLvAl4L4sy+7tdDoHxxg/MzAw8KlOp7PUzIaT3to+NwYMyUpYXiBqRAOHEdWDTiJTLkHFoYVsUYxCCwgQlRiFEAQtDEmHVBLnvS9UNdCLV7SA3efOnfuqkZER1qxZQ6PR2OJ6+muiJEkOFZHXmdmFIvK5I488UkZGRkoDAwOvA3YVkWY/LlOYmSVpDciGevtrQi/Or7FvPGg/YnCDc72NnvRKdEqkidDEaKK2DmMN4hLvk2az2eyISINe7GIDgPd+N4CiKNi4cSMPP/wwo6Oj9EXqr4IWLVp0D/B3RVG8Kc/z7+y9995Lgc7atWt9/5YNwKoYY8PMmnnezXGuRKBFpAk0UBqYTqDoFrxMSTDskTCJ1VCGCQgmZg4vZoZnMoa8UqkMls0siTHuY2YPbauzMUYmJiaYmJggyzJqtRq1Wu0JReyZoKIoaLVaNJtNfvCDH5RGRkZeOmfOnN/ssMMOyxcsWPBGEVl37bXXPnDyySc/L8Y4R0T2EZGFIhLE2ySiE5jMwBCi9QL+Ig7byotvWALxkXi/yRCQRMU5jFhYCXGaxDgeuk2DctL39TXpBcCfkPI8J89zNm/eTJqmU55rSqXS0w5oCIH+epZOp/MoCdiwYcPJ3//+9x/3zNe//vWFJ598MmY2ADRU1RVF4V0+TmahEYxpBBVBAog6ByJWe4ThIglqG3CyCmwepkNmKIZEwUV1DlTF8gZx3GBGGmN0fZ3x+B34j9Bm59xD9BjdqequRVEMbz2oJElI05QkSciyjCRJcM6RJAkissWCAqgqU/NIVSWEQFEU9L3M5Hk+NbnfFq1yzo1OratjjPvTC8azadOmV/bvmWNmDeecxBgTjZMSi9CIQYcR5zykeFUiINQQAZEHzeLaxDTeLUYKzEPcAWLcY6ZmipppjMFI8tEGzeXW9TtnWZYCrFfVA+jtBngUOefuEhEdHh5ePnPmTN2wYYMbHx8HWNV/BmDL4J9BUu/9LWaWzJ49e/nAwIB7+OGHa51OR60XtdsdyNevX39zrVY72Dl3dwghiTGaH1+UabGpFQszEg3OgSgizjnE9u8bkdscdqczC/ejyeL+Mm6WYQ3MopmoBefMJOk21laTxuKKmbrY83aPmlkSQrgeoF6v/wxARO4WkearXvWqHdI0PcE5d2KpVDrhqKOOmiEiLefcfc8kYluT9/7mUqk0/qIXvWh/7/1JaZqeOG3atGOPPfbYrohMAmumTZt23ZIlSxaaWaqqm83MVFVKnQdrne66ajRJXHSiEcQkGjaO0lvOkSxB7QHnE1lCHFyLWc+3H2l5xKtapqYuRpNue6ySFSsXhKCxv05tAHS73dWPEZ3k2GOPnT4yMjL3C1/4AqVSic9+9rOMjIzsfNRRRw3S24X1bJysXGlmpec973kvnT59euUTn/gE8+bN4/TTT+fee+89/MADD1zhnFvfaDRmttvtrK8eGj3VECzLVy0IjfGKRiOqOtRSU0vFaE3hRJi2nsKWJ2xuL9fa7Nc7t7HXtLNGjOwgaoWpYEoSY3emaPeAPG//CkoVEcmdc4QQ5l166aVXNxqNI5MkuQ6we++99yWHHXYYt912G7vvvjs33XQTCxYs4NZbb91XRK53zt1mZjOfcPhPkURk0+zZsx9cuXLlwcPDw6xcuZKhoSHuuusuDj30UFqt1jELFy5cVhTFXO990teteVEUxJi3he4+MXYjgDnUjAg4orWm9nJo2GGmm2bLEnnrzRP6k8MPe8QS41EwkwTFFIsaRIr25qt8vmY8uHlV770lSbIaOOKFL3zh/y5evPhKgOc+97nV733vexx++OEsXbqUiYkJ5s2bx2GHHcbvfvc7jj322MV33nnnI6HUp2nSLfL4PVJHH3307BtuuIHjjz+eK664gvHxcV7xilcwOTnJ9ddfnx1wwAE3NZtNt+uuu74GeKgoCosxGq1VY3lr9CoN7CjO1KI4ExEUxZNN4SSUXiwvu+YT/bCbbRLldoSDMTnSO1seogU1cTEXb2YyvvaOscHB31dG0zdrjFG893cDx+yyyy4bP/OZz5yqqqgqt9xyCz/72c94xzveQQiBgw46iO9973ssWLCA973vfe+cum/Kom5tXacAfeznFEBbfzrnEJFHfe87erdY8F/96lf86Ec/4rTTTuOaa66h0WhwzTXXcPDBB/O2t73tnd1u99uqOlNVfwVYu92VOe3rapMjCyei2lwfxXDOnMTgRQSTl4OB8QczVkJvcyPnvGnuJDI+ihWvAKaJ2kIzqmoiqhBUpNMZr8/ace8j1snzljrnvHMud87tF2Ospmk6bGZOVVmwYAGXXHIJeZ6zdOlS1q9fzx133MEZZ5xBrVZ7FHjbA3Lr348t2+Pex3KhiDBnzhyuuOIKGo0Gq1atYs2aNaxfv54PV+O33AAAECZJREFUfehDJEkSut1uDZihqre3Wm2X5+3unPy3x2548Kpu6sV7QROPpIL3XkYxDu8teev/Kjrjl+dctnpF71W1uFnjzvVHnIV+rSBCRDDBDDWl3GmM/LrUWTbS6XQkxqiqehuwT7fb/Y+pAe68885cfPHFpGmKc45SqcQFF1zAnDlzHgfSE5W+W2pLeTLPPBbsww47jLPOOot2u02e5+y1115cdNFFZFlGv8/7qOqt3W5Xut3cyvmDGzutkSs0UlLFMHEoiIlhbJjCR8PcIWLj1p4o90kvPegaSe/bC2wOcLsZY50CaRdYNzfJI6gbWL3LIe+euyh9+4ZSqSKDg3UnIieq6n3lcnm3vsf4cQP+YwA8lgufSISfSGwfW7z32/s93ul0NojIfFX9z0ajRbPZtP35wZyVt/zbKomTcyspVkqFSoaWUkSEGcCBmKy2uN9id9LCl8NWu7NU9UKsdHEf5YMFNgjgpLcBFpCiPbkTYbKgvXp9nnes3W6rmd0I7Nduty/dlsg9FpjtgbctDvxjL2B79W2v7anS6XQuN7MFwPV5nlur1RLXfXi9FRPtojM5B3D9bXwighNYh3Fgb65culgtfmEKty0A+qHWFZrvNG/LPEddI3WGiLkE8JiKt/TB2340viD9/X7tdtva7bZ18/xhM5swszfGGK/bnu7a3oC2J7pP9P2JxPaJ9KeZEUL4dYzxFBEZy/N89cTEpO902rpP6boDlt98adNjPsE0wSQRIxEDle4ULhp3nu/Xt696HIDy6qVd1Asql/ZMdXy7F5ssiUURvDcRr1i3NT5DWxsmKvnitZONhms1m1oUxdVmVu92u0NmNj4lftsb2JPVgU9WBTwWzG2B2O/ThjzPZ5tZmuf5NZONhmu22jpkS9fE9sho0R4fRhDvRRMxSxLDY2OIvq0nmXIJKm05bWn3cQACOHFna5hzX1+MM8ytS5yId+AEBMErsui671b3Gbjn2G6n02w0GrRarY6ZXQUc0Ol0rrZetOsJOWFbYG5Ld/4xffpk2zGzTp7nd5rZc4HfdDqdvNloWrc12dqrfs+rF1373YokvU2ECeC9+FTEsGQjPbcfxLlLXJJ/+lGYPcr0n3LPerS8D/DbHhfaWxOxsVKCeYdkHhMvRAvDS2/58Y0H1X9fGZ9o0Gw26Xa7m+htAH99nuc/2NoIbP35ZET6sRZ4ayCfTB3barvb7f48xvhK4A+NRmt0YmLSJpsNe/70m+tLbrrsRrUwLe0fB08F6Z2rtzGI7+jpPq6MoTRfTlo6sl0AATq+/U+az76h/1AVlZg6o5xg3uNSj6WefHz9kj1orrCdSsvWTUxMyOjoZtrt9lLgTjN7ewjhB8CfxYl/zpTlCUpeFMUlZnYicHur1Vo+OTnpGo0GOyVLVkvzIR1bt3yPzBNTJ5Y5LHGQelPE5T1JBHTObb7onvFYvB4HYO3kVWuIpRSTb/aXLSd6WJQ6c5nHSg4p9xqS+67+9tCe9QcPk+66NZOTEzI6Okqz1VpsZjer6luLovjZ1jrxyXLlE80Dnwy3TX0C62KMv1PVk4GbGo32srGxMRkd22x0143sOfTQYXf+5t/qpQQyJ5qmWOKQLDHxxiLM3tTXfV/TIknlnSselxXpcQACuEp+Tsx3HERp9Pz/7kWZp1tySEkg8bjUiROscsvln1v7kl2Wvrzb2LhhbGxSxsfGtNlsPqSqV5jZ60IIK1X1uq3r/1PB/BNBm6Lr8zzfqKqvUNX/nZxsPjw2ttmNj08Smps2vnju4iNv+cnn1qVi1VRESg5KhpQTXObIUXdEP/bRiPnsHZzaJ7aJ1bYuykkPt73xbbR+Zu8N2P6ibqycQrmMlZ1ZKYVyIi6VOHzzjz9/99ELlh8dWhvXjI6OsmnTJhsbG5sIIfwXsIOqHhRj/Da9I1mPom0BsD0An+iZrWg8hPDdEMLzgel5nv98fHx8cnRs1MbHN2unuWHkmL0ffMXNl3/+LtEwVErFlRJcmpiVykgpRcXcJrB9eqJbP9OL/5q8c8U2T7E/4WnN8J2df+iTtQKc3If7KyGyf7ONNbqUOoXQbFtoBaJKZc3hJ33igF89sOPVOcNzhoYGQpqWy/V6RUul8qCZHqGqS4CFMcZTVHvO2a0t67asLvC41cTUiuIxn+qc+w/ghc65nUTkd3mej7dardhqtbLJZlMzHXv41c/Z8MqbL/nMIrHG9EqCr1TEVVOjVibUM0g89wFTx14viWHHkLxz9du3h5Hf3h8A57x++i9jrLzV+ZZgzACe6+B3HqYhaIw4J+JMkbwIQyvuvHryZS9//i6tXB9YuSFMC0Xs5W2KoeVElgCY2dFmttjMfk7v8M1g//qWds1sm56XKRAfc20N8J/0gvgvBO4piuKOZrMZx8YmmZycYPPmUXYb3LT+pXtu+ptrf/iJEbHujEpKUstEaplRKxNrKaTCCoR3AB6RJTGfPekle/s5Px3bbuzhjx64bn9tx92yrPigS8b+AcgQNqP8ohuZ28zxrQ7SynHNDtIulE5wxaEn/PP6WJnDT24faJfSSlKtlsvV6oCVKxmlXgCpDOypqrur6m/NbF2Mcb6qvlh7Z+keJbZbr3+dc8E5d4OILPXe7ygiR3rvV5jZgzHGVp7ntNtt2p3cmq2m73ZbjeOfN1nz3TXx1h+fv2PJaVYtOStnSK2C1lJirUxeSlmPcQwwo7e9b+gifPlf5e1rthm+fdIAAoRvzXytSHcv51rn959aCdzcDcxsdPCtHNfuIO0ca+UaWwEGZu+16pDj3vc3Ny1Nfr1oXW1WuVz2pSyxUqki5XK2dSQuU9VhM9vFzKap6gozW2NmDVUt+qcvMxGpi8iOwK5JkkyIyEOqOm5mRVC1kOd0Ojndbod2u0On0427zypWHLlv53X3XfWD69ctv3V+NcPXMmeVBKplQqWEq5eQUsZalBfSOw2PhuqHTct3J+8e+dUfw+bJZ+345vR3kbTrkE8dR1iF8LsisGujQ2xFrNUmtrvUmwXdboHP1RUvPOmfNyYDO02//BbuGu9k8yqlMlk5w7uEUlYy55A0TcQliTkRw0xxHouxd2Cot3PT+kcbxEzEQIIG0SISo1qet6UoAt08aLvb1uGyrj/hMD0gTK7ZeP2PPj+zlGhazoiVBK1lZJUSRb2CVlMk9ayldzJ+DwBi9gG01pV3b3xS2Yz+pLwx8RvDn3RZdwzrgyhsxvhJMPZq52izLVmzwFq5SbdLaEeNndyZlOutw97wwU5anz39ZzeHO9dPMCvxaZp4j08z0iQlSz1Kz/XhxHdxRFR7ESvnPIqPUTPV6LwX63aDKLEXEy6ChVDkc4Z15G8Pyw4qJtZvvOm/v1ixTqNaTpRSySXVFF/NROslpJwY1RKWeBZjnIAw1BtP9gGKclXevfmzTxaTPzlzUfzawBnOhwSXn9dPDpZj9i1DDmp2oR0Iza5qJzhrFfhuR5NOQdENTpPKwNiBx5w6MXOXfY9YtiZcef097e7IhE2XLMlSvDjn8IngncfMgllvQ7JzIoqkGsRUC4kWpCiCodadPuw2vXi/cmXBTslRIyvvvfauX/37YNGdnFbNVDJHUi67WMmIlVSpJs5XMqRWwouzuzB5J5BhqMbkDGflivzD+JMG788CECB8tfZO8cVOzsd/4pF8pz9CGOjm1DoB1+yStgq1dk6RF851g/puTtE10iLSGNpx95EDjjrFTZu928taOXcvebizfMWabvfhTV3f6UI3qqC9o6WC0ywVyiXYeUYp7rZTWpq/c3WPWsYBm9c9ePU9v7lEx9Y/uEOaUMs8oZSQVTJXZF6tZ22dK2eESkYsJf2NU0I/LwKjhPSLEb8i+YfmD/5ULP7s7G321cphEXuv98XeiL2gf3kxwu8N269VENtdF9sFaadQ6/aSDaXdSMgLfKFoEUhiJCcpjw7OnDu+497P1xk77pqV6tNr5drAoE/THUSchLy7odOcnOg2Rpub1q3M1y26zU1sXD1E6ExPElLv0MzjspRQ8iSlhKKSkpRTJ6WUolZSygmZiNyH8VKmMs2Z3BhDtlyRf83e17r1z8HhKaW/m/jywIy65N+XJP4B9JGljsiPMTSiO3cCRTt32im01A3keeF8oap57nxhWsRA0lEwJQQlMcNCz502ldqk109BEwERJHEEcSQlB6knJN6lmdOYlpzLnMZKQpZlrltJ1ZVLpB63CiPF7PgtfTT3KYv+b0TKb5F/HN/852Lw1BMwXobX9dmZmJrL9K3Agv5fOSr/jmdGVJ2bF3Q76nxRqHZy52LUmCsuj06jqu8qgjmiEtTUmEqF0vumgDlx4h2Jd2qJgHcuJk592ROT1PlSopomzpW9xiwl896tQumAvZlH0gcs1sJd4oTAxnCenP3Udko8bTlU7SvMN/NfFW9XAJ9iKmVJL/vkDzEM0V0Ldb5bqObRuagaikBWmLMQCKAuGAFDowKO3gpASbwDBJcICeI08SSJU8kceZKSpGCl1EnqNWJuBYLD7C1bsmBCC+MTMcqrfIjvlQ+y/OkY99ObhNaQ+K8ch+OdPnG3KXwco7xVY/eqyY3iqRk6xyJZUFeEqC4oFOYExaKh9JzaPSMiGOLECw6HpKKWeMyLI/XqxVMIbq1Fmk7scIP9txphxymfiUGfD3zL/3/84ulMifzMpEH+Bmls8yaMU8y535rnNOnP8rdqeA3I1eKsbSolB4NRdFgMb0Y0wcR64mXSSzggggeCw40rTIizrqlUwF5msNOj+gArPVykhR6t8P27q1x2yHt42vcdP6OZzO1sXBjmKODDqvxeEjfN4ANP0JlRRG43GBdljN5OWDCrmWNYYAizgw2mP0EdXzJ0s1NeivDZZDNXP1U990T0rOXSty8wsyuchXMrTLjgmWhDjDNQ3a1kfEr+iY3PRBuPa/PZaGSKDKR9AZc47x6KulUuwqelbrnIqe5c+SdOFJ4+HffHaJse6WeKBKwyyVtDoQca/Eb7qbSfarHItVbovpUB3vxsggfPMoAAcjahW+KNVlhDlZVPGcDIWjFbF5U3yTNgJP7oeJ7tBqdo9F84wMOpivwjsmWS+6dSMLUvpsJ3Bz7CdpMkPpP0FwMQYPyznByUHXFy4Z9VgdrpzjE+7aN8+2nu2pOmvyiAAJvO44tmboNh5/1pT8qnPTpj+se3nRjx2aK/OIBmyKbP8FMzN6bY257MM+LkMjGtzQy89pmc4z0ZetaNyGNJBGtv4k2gczVy+5MwHHcRdVoROOkvDR78FQAIMO+LtIm8zYndr8bodqcrRkuwG73jXTudTeuP1/zM019chLemtWdzpCkvir2EZI8jBx9xCTfNOYvfbev/vwT9VXDgFM05m2sMgiinP477lNMd6F8TePBXxoHQW+6tOYsfFsrDpnyof/GiJGHmzp/mrc/2SuOP0bN7CvpJkICZ4+2rjF8G5RcmDDrHvjt7Xv3XBh78lYnwFMnZhOg5SRxF6hmhyUlyNs/o2dj/X9LKT7D/yo+y31+6H09E/w/wHJVcjfUH5AAAAABJRU5ErkJggg==\",\"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":"<canvas id=\"linearGauge\"></canvas>\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 ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${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\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See advanced settings for details</small>\",\"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\":\"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See advanced settings for details</small>\",\"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":"<canvas id=\"radialGauge\"></canvas>\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 \"<div id=''\" + datasourceId +\n \"'' class=''tbDatasource-container''></div>\"\n );\n\n var datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"<div class=''tbDatasource-title''>\" +\n tbDatasource.name + \"</div>\"\n );\n \n var datasourceTitleCell = $(''.tbDatasource-title'', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = ''table'' + i;\n datasourceContainer.append(\n \"<table id=''\" + tableId +\n \"'' class=''tbDatasource-table''><col width=''30%''><col width=''70%''></table>\"\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(\"<tr><td id=''\" + labelCellId + \"''>\" + dataKey.label +\n \"</td><td id=''\" + cellId +\n \"''></td></tr>\");\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<fieldset class=\"gpio-panel\" ng-disabled=\"!rpcEnabled || executingRpcRequest\" style=\"height: 100%;\">\n <section class=\"gpio-row\" layout=\"row\" ng-repeat=\"row in rows track by $index\" \n ng-style=\"{''height'': prefferedRowHeight+''px''}\">\n <section flex layout=\"row\" ng-repeat=\"cell in row track by $index\">\n <section layout=\"row\" flex ng-if=\"cell\" layout-align=\"{{$index===0 ? ''end center'' : ''start center''}}\">\n <span class=\"gpio-left-label\" ng-show=\"$index===0\">{{ cell.label }}</span>\n <section layout=\"row\" class=\"switch-panel\" layout-align=\"start center\" ng-class=\"$index===0 ? ''col-0'' : ''col-1''\"\n ng-style=\"{''height'': prefferedRowHeight+''px'', ''backgroundColor'': ''{{ switchPanelBackgroundColor }}''}\">\n <span class=\"pin\" ng-show=\"$index===0\">{{cell.pin}}</span>\n <span flex ng-show=\"$index===1\"></span>\n <md-switch\n aria-label=\"{{ cell.label }}\"\n ng-disabled=\"!rpcEnabled || executingRpcRequest\"\n ng-model=\"cell.enabled\" \n ng-change=\"cell.enabled = !cell.enabled\" \n ng-click=\"gpioClick($event, cell)\">\n </md-switch>\n <span flex ng-show=\"$index===0\"></span>\n <span class=\"pin\" ng-show=\"$index===1\">{{cell.pin}}</span>\n </section>\n <span class=\"gpio-right-label\" ng-show=\"$index===1\">{{ cell.label }}</span>\n </section>\n <section layout=\"row\" flex ng-if=\"!cell\">\n <span flex ng-show=\"$index===0\"></span>\n <span class=\"switch-panel\"\n ng-style=\"{''height'': prefferedRowHeight+''px'', ''backgroundColor'': ''{{ switchPanelBackgroundColor }}''}\"></span>\n <span flex ng-show=\"$index===1\"></span>\n </section>\n </section>\n </section> \n <span class=\"error\" style=\"position: absolute; bottom: 5px;\" ng-show=\"rpcErrorText\">{{rpcErrorText}}</span>\n <md-progress-linear ng-show=\"executingRpcRequest\" style=\"position: absolute; bottom: 0;\" md-mode=\"indeterminate\"></md-progress-linear> \n</fieldset>","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":"<fieldset class=\"gpio-panel\" ng-disabled=\"!rpcEnabled || executingRpcRequest\" style=\"height: 100%;\">\n <section class=\"gpio-row\" layout=\"row\" ng-repeat=\"row in rows track by $index\" \n ng-style=\"{''height'': prefferedRowHeight+''px''}\">\n <section flex layout=\"row\" ng-repeat=\"cell in row track by $index\">\n <section layout=\"row\" flex ng-if=\"cell\" layout-align=\"{{$index===0 ? ''end center'' : ''start center''}}\">\n <span class=\"gpio-left-label\" ng-show=\"$index===0\">{{ cell.label }}</span>\n <section layout=\"row\" class=\"switch-panel\" layout-align=\"start center\" ng-class=\"$index===0 ? ''col-0'' : ''col-1''\"\n ng-style=\"{''height'': prefferedRowHeight+''px'', ''backgroundColor'': ''{{ switchPanelBackgroundColor }}''}\">\n <span class=\"pin\" ng-show=\"$index===0\">{{cell.pin}}</span>\n <span flex ng-show=\"$index===1\"></span>\n <md-switch\n aria-label=\"{{ cell.label }}\"\n ng-disabled=\"!rpcEnabled || executingRpcRequest\"\n ng-model=\"cell.enabled\" \n ng-change=\"cell.enabled = !cell.enabled\" \n ng-click=\"gpioClick($event, cell)\">\n </md-switch>\n <span flex ng-show=\"$index===0\"></span>\n <span class=\"pin\" ng-show=\"$index===1\">{{cell.pin}}</span>\n </section>\n <span class=\"gpio-right-label\" ng-show=\"$index===1\">{{ cell.label }}</span>\n </section>\n <section layout=\"row\" flex ng-if=\"!cell\">\n <span flex ng-show=\"$index===0\"></span>\n <span class=\"switch-panel\"\n ng-style=\"{''height'': prefferedRowHeight+''px'', ''backgroundColor'': ''{{ switchPanelBackgroundColor }}''}\"></span>\n <span flex ng-show=\"$index===1\"></span>\n </section>\n </section>\n </section> \n <span class=\"error\" style=\"position: absolute; bottom: 5px;\" ng-show=\"rpcErrorText\">{{rpcErrorText}}</span>\n <md-progress-linear ng-show=\"executingRpcRequest\" style=\"position: absolute; bottom: 0;\" md-mode=\"indeterminate\"></md-progress-linear> \n</fieldset>","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":"<canvas id=\"digitalGauge\"></canvas>\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":"<canvas id=\"digitalGauge\"></canvas>","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":"<canvas id=\"digitalGauge\"></canvas>","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 ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${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\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See advanced settings for details</small>\",\"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\":\"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}<br/><b>Temperature:</b> ${temperature} °C<br/><small>See advanced settings for details</small>\",\"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":"<canvas id=\"radialGauge\"></canvas>\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
dao/src/main/resources/sql/schema-ts.sql 39(+39 -0)
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 0000000..53bc15a
--- /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)
+);
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 48ba2fa..a9d2cbc 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 f10462d..55c2f70 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/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java
index 1e1f15d..f49c357 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;
@@ -89,6 +90,9 @@ public abstract class AbstractServiceTest {
protected AssetService assetService;
@Autowired
+ protected EntityViewService entityViewService;
+
+ @Autowired
protected DeviceCredentialsService deviceCredentialsService;
@Autowired
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 4528d9a..81de40a 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<TsKvEntry> 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<TsKvEntry> 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<String> 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,10 +143,16 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest {
List<TsKvEntry> 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
- public void testDeleteDeviceTsData() throws Exception {
+ public void testDeleteDeviceTsDataWithoutOverwritingLatest() throws Exception {
DeviceId deviceId = new DeviceId(UUIDs.timeBased());
saveEntries(deviceId, 10000);
@@ -123,6 +172,26 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest {
}
@Test
+ public void testDeleteDeviceTsDataWithOverwritingLatest() throws Exception {
+ DeviceId deviceId = new DeviceId(UUIDs.timeBased());
+
+ saveEntries(deviceId, 10000);
+ saveEntries(deviceId, 20000);
+ saveEntries(deviceId, 30000);
+ saveEntries(deviceId, 40000);
+
+ tsService.remove(deviceId, Collections.singletonList(
+ new BaseDeleteTsKvQuery(STRING_KEY, 25000, 45000, true))).get();
+
+ List<TsKvEntry> list = tsService.findAll(deviceId, Collections.singletonList(
+ new BaseReadTsKvQuery(STRING_KEY, 5000, 45000, 10000, 10, Aggregation.NONE))).get();
+ Assert.assertEquals(2, list.size());
+
+ List<TsKvEntry> 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());
List<TsKvEntry> entries = new ArrayList<>();
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 3f65184..9e56d64 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/application-test.properties b/dao/src/test/resources/application-test.properties
index 20cf91c..3bd0f7d 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
@@ -32,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
diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties
index e37e228..7c3ec51 100644
--- a/dao/src/test/resources/nosql-test.properties
+++ b/dao/src/test/resources/nosql-test.properties
@@ -1,6 +1,2 @@
-database.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
+database.entities.type=cassandra
+database.ts.type=cassandra
diff --git a/dao/src/test/resources/sql/drop-all-tables.sql b/dao/src/test/resources/sql/drop-all-tables.sql
index 23b6a56..1bdc1a7 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_view;
diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties
index 1f34b98..3357425 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
docker/.env 25(+15 -10)
diff --git a/docker/.env b/docker/.env
index e330632..ef0bf40 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 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=postgres
+
+LOAD_BALANCER_NAME=haproxy-certbot
docker/.gitignore 7(+7 -0)
diff --git a/docker/.gitignore b/docker/.gitignore
new file mode 100644
index 0000000..bc09e84
--- /dev/null
+++ b/docker/.gitignore
@@ -0,0 +1,7 @@
+haproxy/certs.d/**
+haproxy/letsencrypt/**
+tb-node/log/**
+tb-node/db/**
+tb-node/postgres/**
+tb-node/cassandra/**
+!.env
docker/compose-utils.sh 50(+50 -0)
diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh
new file mode 100755
index 0000000..d1dc20e
--- /dev/null
+++ b/docker/compose-utils.sh
@@ -0,0 +1,50 @@
+#!/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
+ 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 postgres or cassandra." >&2
+ exit 1
+ esac
+ echo $ADDITIONAL_COMPOSE_ARGS
+}
+
+function additionalStartupServices() {
+ source .env
+ ADDITIONAL_STARTUP_SERVICES=""
+ case $DATABASE in
+ postgres)
+ ADDITIONAL_STARTUP_SERVICES=postgres
+ ;;
+ cassandra)
+ ADDITIONAL_STARTUP_SERVICES=cassandra
+ ;;
+ *)
+ echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either postgres or cassandra." >&2
+ exit 1
+ esac
+ echo $ADDITIONAL_STARTUP_SERVICES
+}
docker/docker-compose.cassandra.yml 40(+40 -0)
diff --git a/docker/docker-compose.cassandra.yml b/docker/docker-compose.cassandra.yml
new file mode 100644
index 0000000..721ea3e
--- /dev/null
+++ b/docker/docker-compose.cassandra.yml
@@ -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.
+#
+
+version: '2.2'
+
+services:
+ cassandra:
+ restart: always
+ image: "cassandra:3.11.3"
+ ports:
+ - "9042"
+ volumes:
+ - ./tb-node/cassandra:/var/lib/cassandra
+ tb1:
+ env_file:
+ - tb-node.cassandra.env
+ depends_on:
+ - kafka
+ - redis
+ - cassandra
+ tb2:
+ env_file:
+ - tb-node.cassandra.env
+ depends_on:
+ - kafka
+ - redis
+ - cassandra
docker/docker-compose.postgres.volumes.yml 36(+36 -0)
diff --git a/docker/docker-compose.postgres.volumes.yml b/docker/docker-compose.postgres.volumes.yml
new file mode 100644
index 0000000..d94b066
--- /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}
docker/docker-compose.postgres.yml 42(+42 -0)
diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml
new file mode 100644
index 0000000..c81359a
--- /dev/null
+++ b/docker/docker-compose.postgres.yml
@@ -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.
+#
+
+version: '2.2'
+
+services:
+ postgres:
+ restart: always
+ image: "postgres:9.6"
+ ports:
+ - "5432"
+ environment:
+ POSTGRES_DB: thingsboard
+ volumes:
+ - ./tb-node/postgres:/var/lib/postgresql/data
+ tb1:
+ env_file:
+ - tb-node.postgres.env
+ depends_on:
+ - kafka
+ - redis
+ - postgres
+ tb2:
+ env_file:
+ - tb-node.postgres.env
+ depends_on:
+ - kafka
+ - redis
+ - postgres
docker/docker-compose.yml 175(+150 -25)
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 89a8369..b9d8fd1 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -14,40 +14,165 @@
# limitations under the License.
#
-version: '2'
+
+version: '2.2'
services:
- tb:
- image: "thingsboard/application:2.1.0"
+ zookeeper:
+ restart: always
+ image: "zookeeper:3.5"
ports:
- - "8080:8080"
- - "1883:1883"
- - "5683:5683/udp"
+ - "2181"
+ kafka:
+ restart: always
+ image: "wurstmeister/kafka"
+ ports:
+ - "9092:9092"
env_file:
- - tb.env
+ - 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
+ env_file:
+ - tb-js-executor.env
+ depends_on:
+ - kafka
+ tb1:
+ 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:
- - ADD_SCHEMA_AND_SYSTEM_DATA=${ADD_SCHEMA_AND_SYSTEM_DATA}
- - ADD_DEMO_DATA=${ADD_DEMO_DATA}
+ TB_HOST: tb1
+ env_file:
+ - tb-node.env
volumes:
- - "${HSQLDB_DATA_DIR}:/usr/share/thingsboard/data/sql"
- entrypoint: /run-application.sh
- cassandra:
- image: "cassandra:3.11.2"
+ - ./tb-node/conf:/config
+ - ./tb-node/log:/var/log/thingsboard
+ depends_on:
+ - kafka
+ - redis
+ - tb-js-executor
+ tb2:
+ restart: always
+ image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}"
ports:
- - "9042"
- - "9160"
+ - "8080"
+ logging:
+ driver: "json-file"
+ options:
+ max-size: "200m"
+ max-file: "30"
+ environment:
+ TB_HOST: tb2
+ env_file:
+ - tb-node.env
volumes:
- - "${CASSANDRA_DATA_DIR}:/var/lib/cassandra"
- zk:
- image: "zookeeper:3.4.10"
+ - ./tb-node/conf:/config
+ - ./tb-node/log:/var/log/thingsboard
+ depends_on:
+ - kafka
+ - redis
+ - tb-js-executor
+ tb-mqtt-transport1:
+ restart: always
+ image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}"
ports:
- - "2181"
+ - "1883"
+ env_file:
+ - tb-mqtt-transport.env
+ depends_on:
+ - kafka
+ tb-mqtt-transport2:
restart: always
- postgres:
- image: "postgres:9.6"
+ image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}"
ports:
- - "5432"
- environment:
- - POSTGRES_DB=${POSTGRES_DB}
+ - "1883"
+ 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"
+ 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"
+ 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"
+ 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"
+ env_file:
+ - tb-web-ui.env
+ tb-web-ui2:
+ restart: always
+ image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}"
+ ports:
+ - "8080"
+ env_file:
+ - tb-web-ui.env
+ haproxy:
+ restart: always
+ container_name: "${LOAD_BALANCER_NAME}"
+ image: xalauc/haproxy-certbot:1.7.9
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"
+ - "8080"
+ - "443:443"
+ - "1883:1883"
+ - "9999:9999"
+ cap_add:
+ - NET_ADMIN
+ environment:
+ 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
+ - tb-mqtt-transport2
+ - tb-http-transport1
+ - tb-http-transport2
docker/docker-install-tb.sh 56(+56 -0)
diff --git a/docker/docker-install-tb.sh b/docker/docker-install-tb.sh
new file mode 100755
index 0000000..9032e2f
--- /dev/null
+++ b/docker/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.
+#
+
+./check-dirs.sh
+
+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
+
+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 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} tb1
+
+
docker/docker-upgrade-tb.sh 55(+55 -0)
diff --git a/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh
new file mode 100755
index 0000000..d83b1b7
--- /dev/null
+++ b/docker/docker-upgrade-tb.sh
@@ -0,0 +1,55 @@
+#!/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
+
+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
+
+set -e
+
+source compose-utils.sh
+
+ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $?
+
+ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $?
+
+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 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} tb1
docker/haproxy/config/haproxy.cfg 107(+107 -0)
diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg
new file mode 100644
index 0000000..8ba0ce5
--- /dev/null
+++ b/docker/haproxy/config/haproxy.cfg
@@ -0,0 +1,107 @@
+#HA Proxy Config
+global
+ ulimit-n 500000
+ maxconn 99999
+ maxpipes 99999
+ tune.maxaccept 500
+
+ 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
+
+ log global
+
+ mode http
+
+ 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
+ stats enable
+ stats hide-version
+ 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 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}
+
+ option forwardfor
+
+ 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 !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
+
+ 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
+
+ option forwardfor
+
+ reqadd X-Forwarded-Proto:\ https
+
+ acl transport_http_acl path_beg /api/v1/
+ use_backend tb-http-backend if transport_http_acl
+
+ 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
+
+backend tb-web-backend
+ balance leastconn
+ option tcp-check
+ option log-health-checks
+ 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 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 inter 5s resolvers docker_resolver resolve-prefer ipv4
+ server tbApi2 tb2:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4
docker/kafka.env 12(+12 -0)
diff --git a/docker/kafka.env b/docker/kafka.env
new file mode 100644
index 0000000..87dad07
--- /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=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
+KAFKA_LOG_RETENTION_MS=300000
+KAFKA_LOG_CLEANUP_POLICY=delete
docker/README.md 95(+95 -0)
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 0000000..c43a136
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,95 @@
+# Docker configuration for ThingsBoard Microservices
+
+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
+
+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:
+
+- `postgres` - use PostgreSQL database;
+- `cassandra` - use Cassandra database;
+
+**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:
+
+`
+$ ./docker-install-tb.sh --loadDemo
+`
+
+Where:
+
+- `--loadDemo` - optional argument. Whether to load additional demo data.
+
+## Running
+
+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://{your-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 tb1
+`
+
+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:
+
+`
+$ ./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 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:
+
+```
+$ ./docker-stop-services.sh
+$ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION]
+$ ./docker-start-services.sh
+```
+
+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.
docker/tb-coap-transport.env 6(+6 -0)
diff --git a/docker/tb-coap-transport.env b/docker/tb-coap-transport.env
new file mode 100644
index 0000000..ed8f78d
--- /dev/null
+++ b/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=kafka:9092
\ No newline at end of file
docker/tb-http-transport.env 6(+6 -0)
diff --git a/docker/tb-http-transport.env b/docker/tb-http-transport.env
new file mode 100644
index 0000000..c836323
--- /dev/null
+++ b/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=kafka:9092
\ No newline at end of file
docker/tb-js-executor.env 7(+7 -0)
diff --git a/docker/tb-js-executor.env b/docker/tb-js-executor.env
new file mode 100644
index 0000000..f7f24ac
--- /dev/null
+++ b/docker/tb-js-executor.env
@@ -0,0 +1,7 @@
+
+REMOTE_JS_EVAL_REQUEST_TOPIC=js.eval.requests
+TB_KAFKA_SERVERS=kafka:9092
+LOGGER_LEVEL=info
+LOG_FOLDER=logs
+LOGGER_FILENAME=tb-js-executor-%DATE%.log
+DOCKER_MODE=true
\ No newline at end of file
docker/tb-mqtt-transport.env 6(+6 -0)
diff --git a/docker/tb-mqtt-transport.env b/docker/tb-mqtt-transport.env
new file mode 100644
index 0000000..b024c7a
--- /dev/null
+++ b/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=kafka:9092
\ No newline at end of file
docker/tb-node.cassandra.env 5(+5 -0)
diff --git a/docker/tb-node.cassandra.env b/docker/tb-node.cassandra.env
new file mode 100644
index 0000000..8d813b9
--- /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
docker/tb-node.env 10(+10 -0)
diff --git a/docker/tb-node.env b/docker/tb-node.env
new file mode 100644
index 0000000..ca945ab
--- /dev/null
+++ b/docker/tb-node.env
@@ -0,0 +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
docker/tb-node.postgres.env 9(+9 -0)
diff --git a/docker/tb-node.postgres.env b/docker/tb-node.postgres.env
new file mode 100644
index 0000000..9fa79e3
--- /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
docker/tb-node/conf/logback.xml 51(+51 -0)
diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml
new file mode 100644
index 0000000..6ec2d0b
--- /dev/null
+++ b/docker/tb-node/conf/logback.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>/var/log/thingsboard/${TB_HOST}/thingsboard.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>/var/log/thingsboard/${TB_HOST}/thingsboard.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+ <logger name="akka" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ No newline at end of file
docker/tb-node/conf/thingsboard.conf 24(+24 -0)
diff --git a/docker/tb-node/conf/thingsboard.conf b/docker/tb-node/conf/thingsboard.conf
new file mode 100644
index 0000000..aa430b4
--- /dev/null
+++ b/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/${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"
+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
docker/tb-web-ui.env 9(+9 -0)
diff --git a/docker/tb-web-ui.env b/docker/tb-web-ui.env
new file mode 100644
index 0000000..dbf8120
--- /dev/null
+++ b/docker/tb-web-ui.env
@@ -0,0 +1,9 @@
+
+HTTP_BIND_ADDRESS=0.0.0.0
+HTTP_BIND_PORT=8080
+TB_HOST=haproxy
+TB_PORT=8080
+LOGGER_LEVEL=info
+LOG_FOLDER=logs
+LOGGER_FILENAME=tb-web-ui-%DATE%.log
+DOCKER_MODE=true
\ No newline at end of file
msa/black-box-tests/pom.xml 110(+110 -0)
diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml
new file mode 100644
index 0000000..9f5ac09
--- /dev/null
+++ b/msa/black-box-tests/pom.xml
@@ -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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>msa</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <artifactId>black-box-tests</artifactId>
+
+ <name>ThingsBoard Black Box Tests</name>
+ <url>https://thingsboard.io</url>
+ <description>Project for ThingsBoard black box testing with using Docker</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../..</main.dir>
+ <blackBoxTests.skip>true</blackBoxTests.skip>
+ <testcontainers.version>1.9.1</testcontainers.version>
+ <zeroturnaround.version>1.10</zeroturnaround.version>
+ <java-websocket.version>1.3.9</java-websocket.version>
+ <httpclient.version>4.5.6</httpclient.version>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.testcontainers</groupId>
+ <artifactId>testcontainers</artifactId>
+ <version>${testcontainers.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.zeroturnaround</groupId>
+ <artifactId>zt-exec</artifactId>
+ <version>${zeroturnaround.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.java-websocket</groupId>
+ <artifactId>Java-WebSocket</artifactId>
+ <version>${java-websocket.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.httpcomponents</groupId>
+ <artifactId>httpclient</artifactId>
+ <version>${httpclient.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>io.takari.junit</groupId>
+ <artifactId>takari-cpsuite</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>ch.qos.logback</groupId>
+ <artifactId>logback-classic</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-lang3</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>netty-mqtt</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>tools</artifactId>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <configuration>
+ <includes>
+ <include>**/*TestSuite.java</include>
+ </includes>
+ <skipTests>${blackBoxTests.skip}</skipTests>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
msa/black-box-tests/README.md 23(+23 -0)
diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md
new file mode 100644
index 0000000..044fe00
--- /dev/null
+++ b/msa/black-box-tests/README.md
@@ -0,0 +1,23 @@
+
+## Black box tests execution
+To run the black box tests with using Docker, the local Docker images of Thingsboard's microservices should be built. <br />
+- 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
+
+
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
new file mode 100644
index 0000000..4c3ac83
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java
@@ -0,0 +1,206 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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.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.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;
+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.security.cert.X509Certificate;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+
+@Slf4j
+public abstract class AbstractContainerTest {
+ 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() throws Exception {
+ restClient = new RestClient(HTTPS_URL);
+ 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");
+ }
+
+ 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(property.toString(), cmd);
+ wsClient.send(wsRequest.toString());
+ wsClient.waitForFirstReply();
+ return wsClient;
+ }
+
+ protected Map<String, Long> getExpectedLatestValues(long ts) {
+ return ImmutableMap.<String, Long>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<Object> 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<Object> 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;
+ }
+
+ 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<ConnectionSocketFactory> socketFactoryRegistry = RegistryBuilder
+ .<ConnectionSocketFactory>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/black-box-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
new file mode 100644
index 0000000..a6e89de
--- /dev/null
+++ b/msa/black-box-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 com.google.common.collect.Sets;
+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.mapper.WsTelemetryResponse;
+
+public class HttpClientTest extends AbstractContainerTest {
+
+ @Test
+ public void telemetryUpload() throws Exception {
+ restClient.login("tenant@thingsboard.org", "tenant");
+
+ Device device = createDevice("http_");
+ DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId());
+
+ WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS);
+ ResponseEntity deviceTelemetryResponse = restClient.getRestTemplate()
+ .postForEntity(HTTPS_URL + "/api/v1/{credentialsId}/telemetry",
+ mapper.readTree(createPayload().toString()),
+ ResponseEntity.class,
+ deviceCredentials.getCredentialsId());
+ Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful());
+ WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+ wsClient.closeBlocking();
+
+ 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(HTTPS_URL + "/api/device/" + device.getId());
+ }
+}
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
new file mode 100644
index 0000000..9918963
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java
@@ -0,0 +1,400 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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 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.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;
+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.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.*;
+
+@Slf4j
+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 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()));
+ WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+ log.info("Received telemetry: {}", actualLatestTelemetry);
+ wsClient.closeBlocking();
+
+ 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(HTTPS_URL + "/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 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()));
+ WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+ log.info("Received telemetry: {}", actualLatestTelemetry);
+ wsClient.closeBlocking();
+
+ Assert.assertEquals(4, actualLatestTelemetry.getData().size());
+ 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(HTTPS_URL + "/api/device/" + device.getId());
+ }
+
+ @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()));
+ WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage();
+ log.info("Received telemetry: {}", actualLatestTelemetry);
+ wsClient.closeBlocking();
+
+ 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, MqttQoS.AT_LEAST_ONCE);
+ // 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);
+ log.info("Received telemetry: {}", attributes);
+
+ 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, MqttQoS.AT_LEAST_ONCE);
+
+ 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, MqttQoS.AT_LEAST_ONCE);
+
+ // Send an RPC from the server
+ JsonObject serverRpcPayload = new JsonObject();
+ serverRpcPayload.addProperty("method", "getValue");
+ serverRpcPayload.addProperty("params", true);
+ ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());
+ ListenableFuture<ResponseEntity> 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, MqttQoS.AT_LEAST_ONCE);
+
+ // 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();
+
+ TimeUnit.SECONDS.sleep(3);
+ // 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<RuleChain> 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<RuleChain> 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<RuleChainMetaData> 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<RuleChain> 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<TextPageData<RuleChain>> ruleChains = restClient.getRestTemplate().exchange(
+ HTTPS_URL + "/api/ruleChains?limit=40&textSearch=",
+ HttpMethod.GET,
+ null,
+ new ParameterizedTypeReference<TextPageData<RuleChain>>() {
+ });
+
+ Optional<RuleChain> 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, 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).get();
+ return mqttClient;
+ }
+
+ @Data
+ private class MqttMessageListener implements MqttHandler {
+ private final BlockingQueue<MqttEvent> events;
+
+ private MqttMessageListener() {
+ events = new ArrayBlockingQueue<>(100);
+ }
+
+ @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)));
+ }
+ }
+
+ @Data
+ private class MqttEvent {
+ private final String topic;
+ private final String message;
+ }
+}
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
new file mode 100644
index 0000000..3233617
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java
@@ -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.
+ */
+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 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 0000000..25d2e6e
--- /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<File> composeFiles;
+ private final String identifier;
+ private String cmd = "";
+ private Map<String, String> env = new HashMap<>();
+
+ public DockerComposeExecutor(List<File> 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<String, String> 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<String, String> environment = Maps.newHashMap(env);
+ environment.put(ENV_PROJECT_NAME, identifier);
+ final Stream<String> 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<String> 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<String> 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<File> 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 0000000..fef69b5
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java
@@ -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.
+ */
+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<File> 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<String, String> 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() {
+ 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 " + volumeName + ":/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();
+ }
+
+}
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
new file mode 100644
index 0000000..fa3a63a
--- /dev/null
+++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java
@@ -0,0 +1,92 @@
+/**
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF 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 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 static final ObjectMapper mapper = new ObjectMapper();
+ private WsTelemetryResponse message;
+
+ private volatile boolean firstReplyReceived;
+ private CountDownLatch firstReply = new CountDownLatch(1);
+ private CountDownLatch latch = new CountDownLatch(1);
+
+ WsClient(URI serverUri) {
+ super(serverUri);
+ }
+
+ @Override
+ public void onOpen(ServerHandshake serverHandshake) {
+ }
+
+ @Override
+ public void onMessage(String message) {
+ 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");
+ }
+ }
+ }
+
+ @Override
+ public void onClose(int code, String reason, boolean remote) {
+ log.info("ws is closed, due to [{}]", reason);
+ }
+
+ @Override
+ public void onError(Exception ex) {
+ ex.printStackTrace();
+ }
+
+ 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;
+ }
+
+ 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/resources/RpcResponseRuleChainMetadata.json b/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json
new file mode 100644
index 0000000..09178ef
--- /dev/null
+++ b/msa/black-box-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
msa/js-executor/.gitignore 32(+32 -0)
diff --git a/msa/js-executor/.gitignore b/msa/js-executor/.gitignore
new file mode 100644
index 0000000..1aac685
--- /dev/null
+++ b/msa/js-executor/.gitignore
@@ -0,0 +1,32 @@
+*.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
+api/*.proto.js
msa/js-executor/api/jsExecutor.js 48(+48 -0)
diff --git a/msa/js-executor/api/jsExecutor.js b/msa/js-executor/api/jsExecutor.js
new file mode 100644
index 0000000..18d3f6c
--- /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;
msa/js-executor/api/jsInvokeMessageProcessor.js 226(+226 -0)
diff --git a/msa/js-executor/api/jsInvokeMessageProcessor.js b/msa/js-executor/api/jsInvokeMessageProcessor.js
new file mode 100644
index 0000000..ef81adf
--- /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
msa/js-executor/build.gradle 120(+120 -0)
diff --git a/msa/js-executor/build.gradle b/msa/js-executor/build.gradle
new file mode 100644
index 0000000..f29dbec
--- /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 0000000..d63a504
--- /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"
msa/js-executor/config/default.yml 26(+26 -0)
diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml
new file mode 100644
index 0000000..5933780
--- /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"
msa/js-executor/config/logger.js 59(+59 -0)
diff --git a/msa/js-executor/config/logger.js b/msa/js-executor/config/logger.js
new file mode 100644
index 0000000..695b453
--- /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' || 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
msa/js-executor/docker/Dockerfile 28(+28 -0)
diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile
new file mode 100644
index 0000000..30c6594
--- /dev/null
+++ b/msa/js-executor/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 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
+
+RUN update-rc.d ${pkg.name} disable
+
+CMD ["start-js-executor.sh"]
msa/js-executor/docker/start-js-executor.sh 29(+29 -0)
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 0000000..af7c686
--- /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"
msa/js-executor/install.js 42(+42 -0)
diff --git a/msa/js-executor/install.js b/msa/js-executor/install.js
new file mode 100644
index 0000000..a6bf8d8
--- /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);
+}
msa/js-executor/package.json 39(+39 -0)
diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json
new file mode 100644
index 0000000..fc1665b
--- /dev/null
+++ b/msa/js-executor/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "thingsboard-js-executor",
+ "private": true,
+ "version": "2.2.0",
+ "description": "ThingsBoard JavaScript Executor Microservice",
+ "main": "server.js",
+ "bin": "server.js",
+ "scripts": {
+ "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",
+ "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",
+ "long": "^4.0.0",
+ "protobufjs": "^6.8.8",
+ "uuid-parse": "^1.0.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"
+ }
+}
msa/js-executor/pom.xml 399(+399 -0)
diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml
new file mode 100644
index 0000000..e673a53
--- /dev/null
+++ b/msa/js-executor/pom.xml
@@ -0,0 +1,399 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>msa</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <artifactId>js-executor</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard JavaScript Executor Microservice</name>
+ <url>https://thingsboard.io</url>
+ <description>Service executing JavaScript functions in sandboxed environment</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../..</main.dir>
+ <pkg.name>tb-js-executor</pkg.name>
+ <docker.name>tb-js-executor</docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ <pkg.linux.dist>${project.build.directory}/package/linux</pkg.linux.dist>
+ <pkg.win.dist>${project.build.directory}/package/windows</pkg.win.dist>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.github.eirslett</groupId>
+ <artifactId>frontend-maven-plugin</artifactId>
+ <version>1.0</version>
+ <configuration>
+ <installDirectory>target</installDirectory>
+ <workingDirectory>${basedir}</workingDirectory>
+ </configuration>
+ <executions>
+ <execution>
+ <id>install node and npm</id>
+ <goals>
+ <goal>install-node-and-npm</goal>
+ </goals>
+ <configuration>
+ <nodeVersion>v8.11.3</nodeVersion>
+ <npmVersion>5.6.0</npmVersion>
+ </configuration>
+ </execution>
+ <execution>
+ <id>npm install</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+ <configuration>
+ <arguments>install</arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-winsw-service</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <destFileName>service.exe</destFileName>
+ </artifactItem>
+ </artifactItems>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-linux-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.linux.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>config</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-linux-init</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.linux.dist}/init</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/init</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-win-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>config</directory>
+ <excludes>
+ <exclude>tb-js-executor.conf</exclude>
+ </excludes>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/control</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/control</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-windows-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/windows</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-docker-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.fortasoft</groupId>
+ <artifactId>gradle-maven-plugin</artifactId>
+ <configuration>
+ <tasks>
+ <task>build</task>
+ <task>buildDeb</task>
+ <task>buildRpm</task>
+ </tasks>
+ <args>
+ <arg>-PprojectBuildDir=${project.build.directory}</arg>
+ <arg>-PprojectVersion=${project.version}</arg>
+ <arg>-PpkgName=${pkg.name}</arg>
+ <arg>-PpkgUser=${pkg.user}</arg>
+ <arg>-PpkgInstallFolder=${pkg.installFolder}</arg>
+ <arg>-PpkgLogFolder=${pkg.unixLogFolder}</arg>
+ </args>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>invoke</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <version>3.0.0</version>
+ <configuration>
+ <finalName>${pkg.name}</finalName>
+ <descriptors>
+ <descriptor>src/main/assembly/windows.xml</descriptor>
+ </descriptors>
+ </configuration>
+ <executions>
+ <execution>
+ <id>assembly</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>npm-start</id>
+ <activation>
+ <property>
+ <name>npm-start</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.github.eirslett</groupId>
+ <artifactId>frontend-maven-plugin</artifactId>
+ <version>1.0</version>
+ <configuration>
+ <installDirectory>target</installDirectory>
+ <workingDirectory>${basedir}</workingDirectory>
+ </configuration>
+ <executions>
+ <execution>
+ <id>npm start</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+
+ <configuration>
+ <arguments>start</arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/js-executor/server.js 105(+105 -0)
diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js
new file mode 100644
index 0000000..17f70cb
--- /dev/null
+++ b/msa/js-executor/server.js
@@ -0,0 +1,105 @@
+/*
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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'),
+ kafka = require('kafka-node'),
+ ConsumerGroup = kafka.ConsumerGroup,
+ Producer = kafka.Producer,
+ JsInvokeMessageProcessor = require('./api/jsInvokeMessageProcessor'),
+ logger = require('./config/logger')('main');
+
+var kafkaClient;
+
+(async() => {
+ 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 ConsumerGroup(
+ {
+ kafkaHost: kafkaBootstrapServers,
+ groupId: 'js-executor-group',
+ autoCommit: true,
+ encoding: 'buffer'
+ },
+ 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);
+ logger.error(err.stack);
+ });
+
+ var messageProcessor = new JsInvokeMessageProcessor(producer);
+
+ producer.on('ready', () => {
+ consumer.on('message', (message) => {
+ messageProcessor.onJsInvokeMessage(message);
+ });
+ logger.info('Started ThingsBoard JavaScript Executor Microservice.');
+ });
+
+ } catch (e) {
+ logger.error('Failed to start ThingsBoard JavaScript Executor 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 (kafkaClient) {
+ logger.info('Stopping Kafka Client...');
+ var _kafkaClient = kafkaClient;
+ kafkaClient = null;
+ _kafkaClient.close(() => {
+ logger.info('Kafka Client stopped.');
+ process.exit(status);
+ });
+ } else {
+ process.exit(status);
+ }
+}
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 0000000..7d13715
--- /dev/null
+++ b/msa/js-executor/src/main/assembly/windows.xml
@@ -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.
+
+-->
+<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
+ <id>windows</id>
+
+ <formats>
+ <format>zip</format>
+ </formats>
+
+ <!-- Workaround to create logs directory -->
+ <fileSets>
+ <fileSet>
+ <directory>${pkg.win.dist}</directory>
+ <outputDirectory>logs</outputDirectory>
+ <excludes>
+ <exclude>*/**</exclude>
+ </excludes>
+ </fileSet>
+ <fileSet>
+ <directory>${pkg.win.dist}/conf</directory>
+ <outputDirectory>conf</outputDirectory>
+ <lineEnding>windows</lineEnding>
+ </fileSet>
+ </fileSets>
+
+ <files>
+ <file>
+ <source>${pkg.win.dist}/bin/${pkg.name}.exe</source>
+ <outputDirectory>bin</outputDirectory>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.exe</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.xml</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.xml</destName>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/install.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/uninstall.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ </files>
+</assembly>
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 0000000..8967278
--- /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 0000000..a6e48d9
--- /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 0000000..0767d3f
--- /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 0000000..6186580
--- /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 0000000..d2ebea4
--- /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 0000000..898d3ef
--- /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 0000000..d8021e2
--- /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 0000000..8e1f8a2
--- /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 0000000..db6306e
--- /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 0000000..accb487
--- /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 0000000..f542dd0
--- /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
msa/js-executor/src/main/scripts/init/tb-js-executor 233(+233 -0)
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 0000000..6e8fa52
--- /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 0000000..4da5542
--- /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 0000000..bb91111
--- /dev/null
+++ b/msa/js-executor/src/main/scripts/windows/service.xml
@@ -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.
+
+-->
+<service>
+ <id>${pkg.name}</id>
+ <name>${project.name}</name>
+ <description>${project.description}</description>
+ <workingdirectory>%BASE%\bin</workingdirectory>
+ <logpath>${pkg.winWrapperLogFolder}</logpath>
+ <logmode>rotate</logmode>
+ <env name="NODE_CONFIG_DIR" value="%BASE%\conf" />
+ <env name="LOG_FOLDER" value="${pkg.winWrapperLogFolder}" />
+ <env name="NODE_ENV" value="production" />
+ <executable>%BASE%\bin\${pkg.name}.exe</executable>
+</service>
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 0000000..7061d2a
--- /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
msa/pom.xml 59(+59 -0)
diff --git a/msa/pom.xml b/msa/pom.xml
new file mode 100644
index 0000000..5a9d9ab
--- /dev/null
+++ b/msa/pom.xml
@@ -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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>thingsboard</artifactId>
+ </parent>
+ <artifactId>msa</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard Microservices</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <main.dir>${basedir}/..</main.dir>
+ <docker.repo>thingsboard</docker.repo>
+ <dockerfile.skip>true</dockerfile.skip>
+ </properties>
+
+ <modules>
+ <module>tb</module>
+ <module>js-executor</module>
+ <module>web-ui</module>
+ <module>tb-node</module>
+ <module>transport</module>
+ <module>black-box-tests</module>
+ </modules>
+
+ <build>
+ <pluginManagement>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <version>1.4.5</version>
+ </plugin>
+ </plugins>
+ </pluginManagement>
+ </build>
+
+</project>
msa/tb/docker/install-tb.sh 56(+56 -0)
diff --git a/msa/tb/docker/install-tb.sh b/msa/tb/docker/install-tb.sh
new file mode 100644
index 0000000..e22ec58
--- /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}
msa/tb/docker/logback.xml 51(+51 -0)
diff --git a/msa/tb/docker/logback.xml b/msa/tb/docker/logback.xml
new file mode 100644
index 0000000..87f9bf4
--- /dev/null
+++ b/msa/tb/docker/logback.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>/var/log/thingsboard/thingsboard.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>/var/log/thingsboard/thingsboard.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+ <logger name="akka" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ No newline at end of file
msa/tb/docker/start-tb.sh 39(+39 -0)
diff --git a/msa/tb/docker/start-tb.sh b/msa/tb/docker/start-tb.sh
new file mode 100755
index 0000000..37f5e3a
--- /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
msa/tb/docker/thingsboard.conf 24(+24 -0)
diff --git a/msa/tb/docker/thingsboard.conf b/msa/tb/docker/thingsboard.conf
new file mode 100644
index 0000000..85d7cfd
--- /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
msa/tb/docker/upgrade-tb.sh 47(+47 -0)
diff --git a/msa/tb/docker/upgrade-tb.sh b/msa/tb/docker/upgrade-tb.sh
new file mode 100644
index 0000000..ab77cdd
--- /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
msa/tb/docker-cassandra/Dockerfile 59(+59 -0)
diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile
new file mode 100644
index 0000000..a15408d
--- /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-jdk
+
+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"]
msa/tb/docker-postgres/Dockerfile 62(+62 -0)
diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile
new file mode 100644
index 0000000..c1b3f02
--- /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-jdk
+
+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"]
msa/tb/docker-postgres/start-db.sh 30(+30 -0)
diff --git a/msa/tb/docker-postgres/start-db.sh b/msa/tb/docker-postgres/start-db.sh
new file mode 100644
index 0000000..4b973e6
--- /dev/null
+++ b/msa/tb/docker-postgres/start-db.sh
@@ -0,0 +1,30 @@
+#!/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.
+#
+
+firstlaunch=${DATA_FOLDER}/.firstlaunch
+
+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
+
+su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl -l /var/log/postgres/postgres.log -w start'
+
+if [ ! -f ${firstlaunch} ]; then
+ su postgres -c 'psql -U postgres -d postgres -c "CREATE DATABASE thingsboard"'
+fi
msa/tb/docker-tb/Dockerfile 48(+48 -0)
diff --git a/msa/tb/docker-tb/Dockerfile b/msa/tb/docker-tb/Dockerfile
new file mode 100644
index 0000000..497a03c
--- /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-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/
+
+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"]
msa/tb/pom.xml 370(+370 -0)
diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml
new file mode 100644
index 0000000..6ea7beb
--- /dev/null
+++ b/msa/tb/pom.xml
@@ -0,0 +1,370 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>msa</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <artifactId>tb</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard Docker Images</name>
+ <url>https://thingsboard.io</url>
+ <description>ThingsBoard Docker Images</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../..</main.dir>
+ <pkg.name>thingsboard</pkg.name>
+ <tb.docker.name>tb</tb.docker.name>
+ <tb-postgres.docker.name>tb-postgres</tb-postgres.docker.name>
+ <tb-cassandra.docker.name>tb-cassandra</tb-cassandra.docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ <pkg.upgradeVersion>2.1.1</pkg.upgradeVersion>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>application</artifactId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-tb-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>application</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}/docker-tb</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-tb-postgres-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>application</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}/docker-postgres</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-tb-cassandra-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>application</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}/docker-cassandra</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-docker-tb-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/docker-tb</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ <resource>
+ <directory>docker-tb</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-docker-tb-postgres-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/docker-postgres</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ <resource>
+ <directory>docker-postgres</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-docker-tb-cassandra-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/docker-cassandra</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ <resource>
+ <directory>docker-cassandra</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-tb-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${tb.docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}/docker-tb</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-tb-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${tb.docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ <execution>
+ <id>build-docker-tb-postgres-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${tb-postgres.docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}/docker-postgres</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-tb-postgres-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${tb-postgres.docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ <execution>
+ <id>build-docker-tb-cassandra-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${tb-cassandra.docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}/docker-cassandra</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-tb-cassandra-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${tb-cassandra.docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-tb-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${tb.docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-tb-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${tb.docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-latest-docker-tb-postgres-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${tb-postgres.docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-tb-postgres-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${tb-postgres.docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-latest-docker-tb-cassandra-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${tb-cassandra.docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-tb-cassandra-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${tb-cassandra.docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/tb/README.md 83(+83 -0)
diff --git a/msa/tb/README.md b/msa/tb/README.md
new file mode 100644
index 0000000..e4b7400
--- /dev/null
+++ b/msa/tb/README.md
@@ -0,0 +1,83 @@
+# 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
+
+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:
+
+`
+$ 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, 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"
+> ```
+
+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:
+
+- **System 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**: 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.
msa/tb-node/docker/Dockerfile 28(+28 -0)
diff --git a/msa/tb-node/docker/Dockerfile b/msa/tb-node/docker/Dockerfile
new file mode 100644
index 0000000..57a6151
--- /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-jdk
+
+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"]
msa/tb-node/docker/start-tb-node.sh 71(+71 -0)
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 0000000..7f7e869
--- /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
msa/tb-node/pom.xml 190(+190 -0)
diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml
new file mode 100644
index 0000000..fc62189
--- /dev/null
+++ b/msa/tb-node/pom.xml
@@ -0,0 +1,190 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>msa</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <artifactId>tb-node</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard Node Microservice</name>
+ <url>https://thingsboard.io</url>
+ <description>ThingsBoard Node Microservice</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../..</main.dir>
+ <pkg.name>thingsboard</pkg.name>
+ <docker.name>tb-node</docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>application</artifactId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-tb-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>application</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-docker-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/transport/coap/docker/Dockerfile 31(+31 -0)
diff --git a/msa/transport/coap/docker/Dockerfile b/msa/transport/coap/docker/Dockerfile
new file mode 100644
index 0000000..9240b2a
--- /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-jdk
+
+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"]
msa/transport/coap/docker/logback.xml 50(+50 -0)
diff --git a/msa/transport/coap/docker/logback.xml b/msa/transport/coap/docker/logback.xml
new file mode 100644
index 0000000..e9d8692
--- /dev/null
+++ b/msa/transport/coap/docker/logback.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>/var/log/${pkg.name}/${pkg.name}.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>/var/log/${pkg.name}/${pkg.name}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ 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 0000000..43d4602
--- /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 0000000..6d4c54a
--- /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
msa/transport/coap/pom.xml 190(+190 -0)
diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml
new file mode 100644
index 0000000..26eaa30
--- /dev/null
+++ b/msa/transport/coap/pom.xml
@@ -0,0 +1,190 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa.transport</groupId>
+ <artifactId>coap</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard COAP Transport Microservice</name>
+ <url>https://thingsboard.io</url>
+ <description>ThingsBoard COAP Transport Microservice</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ <pkg.name>tb-coap-transport</pkg.name>
+ <docker.name>tb-coap-transport</docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.logFolder>/var/log/${pkg.name}</pkg.logFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.transport</groupId>
+ <artifactId>coap</artifactId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-tb-coap-transport-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard.transport</groupId>
+ <artifactId>coap</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-docker-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/transport/http/docker/Dockerfile 31(+31 -0)
diff --git a/msa/transport/http/docker/Dockerfile b/msa/transport/http/docker/Dockerfile
new file mode 100644
index 0000000..6c83b9c
--- /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-jdk
+
+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"]
msa/transport/http/docker/logback.xml 50(+50 -0)
diff --git a/msa/transport/http/docker/logback.xml b/msa/transport/http/docker/logback.xml
new file mode 100644
index 0000000..e9d8692
--- /dev/null
+++ b/msa/transport/http/docker/logback.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>/var/log/${pkg.name}/${pkg.name}.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>/var/log/${pkg.name}/${pkg.name}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ 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 0000000..667988f
--- /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 0000000..6d4c54a
--- /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
msa/transport/http/pom.xml 190(+190 -0)
diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml
new file mode 100644
index 0000000..6d1707c
--- /dev/null
+++ b/msa/transport/http/pom.xml
@@ -0,0 +1,190 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa.transport</groupId>
+ <artifactId>http</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard HTTP Transport Microservice</name>
+ <url>https://thingsboard.io</url>
+ <description>ThingsBoard HTTP Transport Microservice</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ <pkg.name>tb-http-transport</pkg.name>
+ <docker.name>tb-http-transport</docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.logFolder>/var/log/${pkg.name}</pkg.logFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.transport</groupId>
+ <artifactId>http</artifactId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-tb-http-transport-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard.transport</groupId>
+ <artifactId>http</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-docker-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/transport/mqtt/docker/Dockerfile 31(+31 -0)
diff --git a/msa/transport/mqtt/docker/Dockerfile b/msa/transport/mqtt/docker/Dockerfile
new file mode 100644
index 0000000..f636e2f
--- /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-jdk
+
+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"]
msa/transport/mqtt/docker/logback.xml 50(+50 -0)
diff --git a/msa/transport/mqtt/docker/logback.xml b/msa/transport/mqtt/docker/logback.xml
new file mode 100644
index 0000000..e9d8692
--- /dev/null
+++ b/msa/transport/mqtt/docker/logback.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>/var/log/${pkg.name}/${pkg.name}.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>/var/log/${pkg.name}/${pkg.name}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ 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 0000000..8fe06d2
--- /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 0000000..6d4c54a
--- /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
msa/transport/mqtt/pom.xml 190(+190 -0)
diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml
new file mode 100644
index 0000000..d51c51c
--- /dev/null
+++ b/msa/transport/mqtt/pom.xml
@@ -0,0 +1,190 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>transport</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa.transport</groupId>
+ <artifactId>mqtt</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard MQTT Transport Microservice</name>
+ <url>https://thingsboard.io</url>
+ <description>ThingsBoard MQTT Transport Microservice</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../../..</main.dir>
+ <pkg.name>tb-mqtt-transport</pkg.name>
+ <docker.name>tb-mqtt-transport</docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.logFolder>/var/log/${pkg.name}</pkg.logFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard.transport</groupId>
+ <artifactId>mqtt</artifactId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-tb-mqtt-transport-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard.transport</groupId>
+ <artifactId>mqtt</artifactId>
+ <classifier>deb</classifier>
+ <type>deb</type>
+ <destFileName>${pkg.name}.deb</destFileName>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-docker-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/transport/pom.xml 55(+55 -0)
diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml
new file mode 100644
index 0000000..9f091e3
--- /dev/null
+++ b/msa/transport/pom.xml
@@ -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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>msa</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <artifactId>transport</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard Transport Microservices</name>
+ <url>https://thingsboard.io</url>
+
+ <properties>
+ <main.dir>${basedir}/../..</main.dir>
+ </properties>
+
+ <modules>
+ <module>mqtt</module>
+ <module>http</module>
+ <module>coap</module>
+ </modules>
+
+ <build>
+ <pluginManagement>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <version>1.4.5</version>
+ </plugin>
+ </plugins>
+ </pluginManagement>
+ </build>
+
+</project>
msa/web-ui/.gitignore 31(+31 -0)
diff --git a/msa/web-ui/.gitignore b/msa/web-ui/.gitignore
new file mode 100644
index 0000000..c5cfd3d
--- /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
msa/web-ui/build.gradle 125(+125 -0)
diff --git a/msa/web-ui/build.gradle b/msa/web-ui/build.gradle
new file mode 100644
index 0000000..7372c0a
--- /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 0000000..9472a50
--- /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"
msa/web-ui/config/default.yml 30(+30 -0)
diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml
new file mode 100644
index 0000000..cf27a14
--- /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"
msa/web-ui/config/logger.js 59(+59 -0)
diff --git a/msa/web-ui/config/logger.js b/msa/web-ui/config/logger.js
new file mode 100644
index 0000000..695b453
--- /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
msa/web-ui/docker/Dockerfile 28(+28 -0)
diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile
new file mode 100644
index 0000000..a4c4ff2
--- /dev/null
+++ b/msa/web-ui/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 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
+
+RUN update-rc.d ${pkg.name} disable
+
+CMD ["start-web-ui.sh"]
msa/web-ui/docker/start-web-ui.sh 29(+29 -0)
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 0000000..af7c686
--- /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"
msa/web-ui/install.js 42(+42 -0)
diff --git a/msa/web-ui/install.js b/msa/web-ui/install.js
new file mode 100644
index 0000000..63e73db
--- /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);
+}
msa/web-ui/package.json 38(+38 -0)
diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json
new file mode 100644
index 0000000..f30f2fc
--- /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"
+ }
+}
msa/web-ui/pom.xml 423(+423 -0)
diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml
new file mode 100644
index 0000000..83124df
--- /dev/null
+++ b/msa/web-ui/pom.xml
@@ -0,0 +1,423 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>org.thingsboard</groupId>
+ <version>2.2.0-SNAPSHOT</version>
+ <artifactId>msa</artifactId>
+ </parent>
+ <groupId>org.thingsboard.msa</groupId>
+ <artifactId>web-ui</artifactId>
+ <packaging>pom</packaging>
+
+ <name>ThingsBoard Web UI Microservice</name>
+ <url>https://thingsboard.io</url>
+ <description>Service for hosting ThingsBoard Web UI</description>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <main.dir>${basedir}/../..</main.dir>
+ <pkg.name>tb-web-ui</pkg.name>
+ <docker.name>tb-web-ui</docker.name>
+ <pkg.user>thingsboard</pkg.user>
+ <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ <pkg.linux.dist>${project.build.directory}/package/linux</pkg.linux.dist>
+ <pkg.win.dist>${project.build.directory}/package/windows</pkg.win.dist>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>ui</artifactId>
+ <version>${project.version}</version>
+ <type>jar</type>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <scope>provided</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.github.eirslett</groupId>
+ <artifactId>frontend-maven-plugin</artifactId>
+ <version>1.0</version>
+ <configuration>
+ <installDirectory>target</installDirectory>
+ <workingDirectory>${basedir}</workingDirectory>
+ </configuration>
+ <executions>
+ <execution>
+ <id>install node and npm</id>
+ <goals>
+ <goal>install-node-and-npm</goal>
+ </goals>
+ <configuration>
+ <nodeVersion>v8.11.3</nodeVersion>
+ <npmVersion>5.6.0</npmVersion>
+ </configuration>
+ </execution>
+ <execution>
+ <id>npm install</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+ <configuration>
+ <arguments>install</arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>extract-web-ui</id>
+ <goals>
+ <goal>unpack</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>org.thingsboard</groupId>
+ <artifactId>ui</artifactId>
+ <type>jar</type>
+ <overWrite>false</overWrite>
+ <outputDirectory>${project.build.directory}/web</outputDirectory>
+ </artifactItem>
+ </artifactItems>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-winsw-service</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <destFileName>service.exe</destFileName>
+ </artifactItem>
+ </artifactItems>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-linux-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.linux.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>config</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-linux-init</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.linux.dist}/init</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/init</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-win-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>config</directory>
+ <excludes>
+ <exclude>tb-web-ui.conf</exclude>
+ </excludes>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/control</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/control</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-windows-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/windows</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-docker-config</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>docker</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.fortasoft</groupId>
+ <artifactId>gradle-maven-plugin</artifactId>
+ <configuration>
+ <tasks>
+ <task>build</task>
+ <task>buildDeb</task>
+ <task>buildRpm</task>
+ </tasks>
+ <args>
+ <arg>-PprojectBuildDir=${project.build.directory}</arg>
+ <arg>-PprojectVersion=${project.version}</arg>
+ <arg>-PpkgName=${pkg.name}</arg>
+ <arg>-PpkgUser=${pkg.user}</arg>
+ <arg>-PpkgInstallFolder=${pkg.installFolder}</arg>
+ <arg>-PpkgLogFolder=${pkg.unixLogFolder}</arg>
+ </args>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>invoke</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <version>3.0.0</version>
+ <configuration>
+ <finalName>${pkg.name}</finalName>
+ <descriptors>
+ <descriptor>src/main/assembly/windows.xml</descriptor>
+ </descriptors>
+ </configuration>
+ <executions>
+ <execution>
+ <id>assembly</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>build-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>build</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <verbose>true</verbose>
+ <googleContainerRegistryEnabled>false</googleContainerRegistryEnabled>
+ <contextDirectory>${project.build.directory}</contextDirectory>
+ </configuration>
+ </execution>
+ <execution>
+ <id>tag-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>tag</goal>
+ </goals>
+ <configuration>
+ <skip>${dockerfile.skip}</skip>
+ <repository>${docker.repo}/${docker.name}</repository>
+ <tag>${project.version}</tag>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <profiles>
+ <profile>
+ <id>npm-start</id>
+ <activation>
+ <property>
+ <name>npm-start</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.github.eirslett</groupId>
+ <artifactId>frontend-maven-plugin</artifactId>
+ <version>1.0</version>
+ <configuration>
+ <installDirectory>target</installDirectory>
+ <workingDirectory>${basedir}</workingDirectory>
+ </configuration>
+ <executions>
+ <execution>
+ <id>npm start</id>
+ <goals>
+ <goal>npm</goal>
+ </goals>
+
+ <configuration>
+ <arguments>start</arguments>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ <profile>
+ <id>push-docker-image</id>
+ <activation>
+ <property>
+ <name>push-docker-image</name>
+ </property>
+ </activation>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>com.spotify</groupId>
+ <artifactId>dockerfile-maven-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>push-latest-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>latest</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ <execution>
+ <id>push-version-docker-image</id>
+ <phase>pre-integration-test</phase>
+ <goals>
+ <goal>push</goal>
+ </goals>
+ <configuration>
+ <tag>${project.version}</tag>
+ <repository>${docker.repo}/${docker.name}</repository>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+</project>
msa/web-ui/server.js 129(+129 -0)
diff --git a/msa/web-ui/server.js b/msa/web-ui/server.js
new file mode 100644
index 0000000..44e175a
--- /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.debug(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);
+ }
+}
msa/web-ui/src/main/assembly/windows.xml 75(+75 -0)
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 0000000..1211990
--- /dev/null
+++ b/msa/web-ui/src/main/assembly/windows.xml
@@ -0,0 +1,75 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
+ <id>windows</id>
+
+ <formats>
+ <format>zip</format>
+ </formats>
+
+ <!-- Workaround to create logs directory -->
+ <fileSets>
+ <fileSet>
+ <directory>${pkg.win.dist}</directory>
+ <outputDirectory>logs</outputDirectory>
+ <excludes>
+ <exclude>*/**</exclude>
+ </excludes>
+ </fileSet>
+ <fileSet>
+ <directory>${pkg.win.dist}/conf</directory>
+ <outputDirectory>conf</outputDirectory>
+ <lineEnding>windows</lineEnding>
+ </fileSet>
+ <fileSet>
+ <directory>${project.build.directory}/web</directory>
+ <outputDirectory>web</outputDirectory>
+ </fileSet>
+ </fileSets>
+
+ <files>
+ <file>
+ <source>${pkg.win.dist}/bin/${pkg.name}.exe</source>
+ <outputDirectory>bin</outputDirectory>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.exe</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.xml</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.xml</destName>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/install.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/uninstall.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ </files>
+</assembly>
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 0000000..8967278
--- /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 0000000..a6e48d9
--- /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 0000000..0767d3f
--- /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 0000000..6186580
--- /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 0000000..d2ebea4
--- /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 0000000..898d3ef
--- /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 0000000..d8021e2
--- /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 0000000..8e1f8a2
--- /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 0000000..db6306e
--- /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 0000000..accb487
--- /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 0000000..f542dd0
--- /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
msa/web-ui/src/main/scripts/init/tb-web-ui 233(+233 -0)
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 0000000..d0d3f95
--- /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 0000000..4da5542
--- /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 0000000..e512aad
--- /dev/null
+++ b/msa/web-ui/src/main/scripts/windows/service.xml
@@ -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.
+
+-->
+<service>
+ <id>${pkg.name}</id>
+ <name>${project.name}</name>
+ <description>${project.description}</description>
+ <workingdirectory>%BASE%\bin</workingdirectory>
+ <logpath>${pkg.winWrapperLogFolder}</logpath>
+ <logmode>rotate</logmode>
+ <env name="NODE_CONFIG_DIR" value="%BASE%\conf" />
+ <env name="LOG_FOLDER" value="${pkg.winWrapperLogFolder}" />
+ <env name="NODE_ENV" value="production" />
+ <env name="WEB_FOLDER" value="%BASE%\web" />
+ <executable>%BASE%\bin\${pkg.name}.exe</executable>
+</service>
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 0000000..7061d2a
--- /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
netty-mqtt/pom.xml 4(+2 -2)
diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml
index 8dffbfd..80e8a5d 100644
--- a/netty-mqtt/pom.xml
+++ b/netty-mqtt/pom.xml
@@ -19,12 +19,12 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<groupId>org.thingsboard</groupId>
<artifactId>netty-mqtt</artifactId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>Netty MQTT Client</name>
pom.xml 60(+35 -25)
diff --git a/pom.xml b/pom.xml
index aa54e72..00c9c28 100755
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>org.thingsboard</groupId>
<artifactId>thingsboard</artifactId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>Thingsboard</name>
@@ -50,7 +50,7 @@
<commons-validator.version>1.5.0</commons-validator.version>
<commons-io.version>2.5</commons-io.version>
<commons-csv.version>1.4</commons-csv.version>
- <jackson.version>2.8.8.1</jackson.version>
+ <jackson.version>2.8.11.1</jackson.version>
<json-schema-validator.version>2.2.6</json-schema-validator.version>
<scala.version>2.11</scala.version>
<akka.version>2.4.2</akka.version>
@@ -66,8 +66,7 @@
<paho.client.version>1.1.0</paho.client.version>
<netty.version>4.1.22.Final</netty.version>
<os-maven-plugin.version>1.5.0</os-maven-plugin.version>
- <rabbitmq.version>3.6.5</rabbitmq.version>
- <kafka.version>0.9.0.0</kafka.version>
+ <rabbitmq.version>4.8.0</rabbitmq.version>
<surfire.version>2.19.1</surfire.version>
<jar-plugin.version>3.0.2</jar-plugin.version>
<springfox-swagger.version>2.6.1</springfox-swagger.version>
@@ -82,6 +81,8 @@
</sonar.exclusions>
<elasticsearch.version>5.0.2</elasticsearch.version>
<delight-nashorn-sandbox.version>0.1.14</delight-nashorn-sandbox.version>
+ <kafka.version>2.0.0</kafka.version>
+ <bucket4j.version>4.1.1</bucket4j.version>
</properties>
<modules>
@@ -93,6 +94,7 @@
<module>ui</module>
<module>tools</module>
<module>application</module>
+ <module>msa</module>
</modules>
<profiles>
@@ -282,6 +284,9 @@
<exclude>src/main/scripts/control/**</exclude>
<exclude>src/main/scripts/windows/**</exclude>
<exclude>src/main/resources/public/static/rulenode/**</exclude>
+ <exclude>**/*.proto.js</exclude>
+ <exclude>docker/haproxy/**</exclude>
+ <exclude>docker/tb-node/**</exclude>
</excludes>
<mapping>
<proto>JAVADOC_STYLE</proto>
@@ -351,18 +356,23 @@
<version>${project.version}</version>
</dependency>
<dependency>
- <groupId>org.thingsboard.transport</groupId>
- <artifactId>http</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
- <groupId>org.thingsboard.transport</groupId>
- <artifactId>coap</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>mqtt</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
- <groupId>org.thingsboard.transport</groupId>
- <artifactId>mqtt</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>http</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>coap</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
@@ -371,6 +381,11 @@
<version>${project.version}</version>
</dependency>
<dependency>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
<groupId>org.thingsboard</groupId>
<artifactId>tools</artifactId>
<version>${project.version}</version>
@@ -415,6 +430,11 @@
<version>${spring-boot.version}</version>
</dependency>
<dependency>
+ <groupId>org.apache.kafka</groupId>
+ <artifactId>kafka-clients</artifactId>
+ <version>${kafka.version}</version>
+ </dependency>
+ <dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.driver.version}</version>
@@ -691,21 +711,6 @@
<scope>provided</scope>
</dependency>
<dependency>
- <groupId>org.apache.kafka</groupId>
- <artifactId>kafka_2.10</artifactId>
- <version>${kafka.version}</version>
- <exclusions>
- <exclusion>
- <groupId>org.slf4j</groupId>
- <artifactId>slf4j-log4j12</artifactId>
- </exclusion>
- <exclusion>
- <groupId>log4j</groupId>
- <artifactId>log4j</artifactId>
- </exclusion>
- </exclusions>
- </dependency>
- <dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>${paho.client.version}</version>
@@ -774,6 +779,11 @@
<artifactId>delight-nashorn-sandbox</artifactId>
<version>${delight-nashorn-sandbox.version}</version>
</dependency>
+ <dependency>
+ <groupId>com.github.vladimir-bukhtoyarov</groupId>
+ <artifactId>bucket4j-core</artifactId>
+ <version>${bucket4j.version}</version>
+ </dependency>
</dependencies>
</dependencyManagement>
rule-engine/pom.xml 2(+1 -1)
diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml
index 00e4980..15341b0 100644
--- a/rule-engine/pom.xml
+++ b/rule-engine/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<artifactId>rule-engine</artifactId>
rule-engine/rule-engine-api/pom.xml 2(+1 -1)
diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml
index 09da1cd..46849b0 100644
--- a/rule-engine/rule-engine-api/pom.xml
+++ b/rule-engine/rule-engine-api/pom.xml
@@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>rule-engine</artifactId>
</parent>
<groupId>org.thingsboard.rule-engine</groupId>
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 9a93d1e..f3d57de 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-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 e7ef0dd..f9d3c64 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/pom.xml b/rule-engine/rule-engine-components/pom.xml
index e52e44b..c22ce78 100644
--- a/rule-engine/rule-engine-components/pom.xml
+++ b/rule-engine/rule-engine-components/pom.xml
@@ -22,7 +22,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>rule-engine</artifactId>
</parent>
<groupId>org.thingsboard.rule-engine</groupId>
@@ -45,8 +45,8 @@
<scope>provided</scope>
</dependency>
<dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>transport</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>transport-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
@@ -72,13 +72,17 @@
<artifactId>guava</artifactId>
</dependency>
<dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ </dependency>
+ <dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.kafka</groupId>
- <artifactId>kafka_2.10</artifactId>
+ <artifactId>kafka-clients</artifactId>
</dependency>
<dependency>
<groupId>com.amazonaws</groupId>
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 0000000..459e35b
--- /dev/null
+++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.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.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;
+import org.thingsboard.rule.engine.api.TbContext;
+import org.thingsboard.rule.engine.api.TbNode;
+import org.thingsboard.rule.engine.api.TbNodeConfiguration;
+import org.thingsboard.rule.engine.api.TbNodeException;
+import org.thingsboard.rule.engine.api.util.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.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.stream.Collectors;
+
+import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS;
+
+@Slf4j
+@RuleNode(
+ type = ComponentType.ACTION,
+ 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 " +
+ "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"
+)
+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) {
+ 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<List<EntityView>> 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)) {
+ if (DataConstants.ATTRIBUTES_UPDATED.equals(msg.getType()) ||
+ SessionMsgType.POST_ATTRIBUTES_REQUEST.name().equals(msg.getType())) {
+ Set<AttributeKvEntry> attributes = JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData()));
+ List<AttributeKvEntry> filteredAttributes =
+ attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).collect(Collectors.toList());
+ ctx.getTelemetryService().saveAndNotify(entityView.getId(), scope, filteredAttributes,
+ new FutureCallback<Void>() {
+ @Override
+ public void onSuccess(@Nullable Void result) {
+ transformAndTellNext(ctx, msg, entityView);
+ }
+
+ @Override
+ public void onFailure(Throwable t) {
+ ctx.tellFailure(msg, t);
+ }
+ });
+ } else if (DataConstants.ATTRIBUTES_DELETED.equals(msg.getType())) {
+ List<String> 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<String> filteredAttributes =
+ attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr, entityView)).collect(Collectors.toList());
+ if (filteredAttributes != null && !filteredAttributes.isEmpty()) {
+ ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes);
+ transformAndTellNext(ctx, msg, entityView);
+ }
+ }
+ }
+ }
+ },
+ t -> ctx.tellFailure(msg, t));
+ } else {
+ ctx.tellFailure(msg, new IllegalArgumentException("Message metadata is empty"));
+ }
+ } else {
+ 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:
+ return entityView.getKeys().getAttributes().getCs().contains(attrKey);
+ case DataConstants.SERVER_SCOPE:
+ return entityView.getKeys().getAttributes().getSs().contains(attrKey);
+ case DataConstants.SHARED_SCOPE:
+ return entityView.getKeys().getAttributes().getSh().contains(attrKey);
+ }
+ return false;
+ }
+
+ @Override
+ public void destroy() {
+ }
+}
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 c568e3d..e3766ba 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<TbMsgG
configuration.setPeriodInSeconds(1);
configuration.setJsScript("var msg = { temp: 42, humidity: 77 };\n" +
"var metadata = { data: 40 };\n" +
- "var msgType = \"DebugMsg\";\n\n" +
+ "var msgType = \"POST_TELEMETRY_REQUEST\";\n\n" +
"return { msg: msg, metadata: metadata, msgType: msgType };");
return configuration;
}
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 f78b186..035a17d 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 <b>\"Post attributes\", \"Post telemetry\", \"RPC Request\"</b> etc. via corresponding chain, otherwise <b>Other</b> 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 {
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 5d7e124..8c95b88 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();
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 5cdb04e..12dbc6d 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<AttributeKvEntry> attributes = JsonConverter.convertToAttributes(new JsonParser().parse(src)).getAttributes();
+ Set<AttributeKvEntry> 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 efdf4af..4cb9999 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<Long, List<KvEntry>> tsKvMap = telemetryUploadRequest.getData();
+ Map<Long, List<KvEntry>> tsKvMap = JsonConverter.convertToTelemetry(new JsonParser().parse(src), ts);
if (tsKvMap == null) {
ctx.tellFailure(msg, new IllegalArgumentException("Msg body is empty: " + src));
return;
tools/pom.xml 2(+1 -1)
diff --git a/tools/pom.xml b/tools/pom.xml
index a386330..f61634f 100644
--- a/tools/pom.xml
+++ b/tools/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<groupId>org.thingsboard</groupId>
transport/coap/build.gradle 140(+140 -0)
diff --git a/transport/coap/build.gradle b/transport/coap/build.gradle
new file mode 100644
index 0000000..6d54cb4
--- /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")
+}
transport/coap/pom.xml 301(+279 -22)
diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml
index 2360a75..a5e8107 100644
--- a/transport/coap/pom.xml
+++ b/transport/coap/pom.xml
@@ -20,49 +20,44 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.transport</groupId>
<artifactId>coap</artifactId>
<packaging>jar</packaging>
- <name>Thingsboard COAP Transport</name>
+ <name>Thingsboard CoAP Transport Service</name>
<url>https://thingsboard.io</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.dir>${basedir}/../..</main.dir>
+ <pkg.name>tb-coap-transport</pkg.name>
+ <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ <pkg.win.dist>${project.build.directory}/windows</pkg.win.dist>
</properties>
<dependencies>
<dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>transport</artifactId>
- </dependency>
- <dependency>
- <groupId>org.eclipse.californium</groupId>
- <artifactId>californium-core</artifactId>
- </dependency>
- <dependency>
- <groupId>org.springframework</groupId>
- <artifactId>spring-context</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>coap</artifactId>
</dependency>
<dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>slf4j-api</artifactId>
- </dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>log4j-over-slf4j</artifactId>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
</dependency>
<dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-core</artifactId>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -81,4 +76,266 @@
</dependency>
</dependencies>
+ <build>
+ <finalName>${pkg.name}-${project.version}</finalName>
+ <resources>
+ <resource>
+ <directory>${project.basedir}/src/main/resources</directory>
+ </resource>
+ </resources>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <excludes>
+ <exclude>logback.xml</exclude>
+ </excludes>
+ <filtering>false</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-service-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/conf</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-win-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <excludes>
+ <exclude>logback.xml</exclude>
+ </excludes>
+ <filtering>false</filtering>
+ </resource>
+ <resource>
+ <directory>src/main/conf</directory>
+ <excludes>
+ <exclude>tb-coap-transport.conf</exclude>
+ </excludes>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/control</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/control</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-windows-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/windows</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-winsw-service</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <destFileName>service.exe</destFileName>
+ </artifactItem>
+ </artifactItems>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <excludes>
+ <exclude>**/logback.xml</exclude>
+ </excludes>
+ <archive>
+ <manifestEntries>
+ <Implementation-Title>ThingsBoard CoAP Transport Service</Implementation-Title>
+ <Implementation-Version>${project.version}</Implementation-Version>
+ </manifestEntries>
+ </archive>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ <configuration>
+ <mainClass>org.thingsboard.server.coap.ThingsboardCoapTransportApplication</mainClass>
+ <classifier>boot</classifier>
+ <layout>ZIP</layout>
+ <executable>true</executable>
+ <excludeDevtools>true</excludeDevtools>
+ <embeddedLaunchScriptProperties>
+ <confFolder>${pkg.installFolder}/conf</confFolder>
+ <logFolder>${pkg.unixLogFolder}</logFolder>
+ <logFilename>${pkg.name}.out</logFilename>
+ <initInfoProvides>${pkg.name}</initInfoProvides>
+ </embeddedLaunchScriptProperties>
+ </configuration>
+ <executions>
+ <execution>
+ <goals>
+ <goal>repackage</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.fortasoft</groupId>
+ <artifactId>gradle-maven-plugin</artifactId>
+ <configuration>
+ <tasks>
+ <task>build</task>
+ <task>buildDeb</task>
+ <task>buildRpm</task>
+ </tasks>
+ <args>
+ <arg>-PprojectBuildDir=${project.build.directory}</arg>
+ <arg>-PprojectVersion=${project.version}</arg>
+ <arg>-PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</arg>
+ <arg>-PpkgName=${pkg.name}</arg>
+ <arg>-PpkgInstallFolder=${pkg.installFolder}</arg>
+ <arg>-PpkgLogFolder=${pkg.unixLogFolder}</arg>
+ </args>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>invoke</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <configuration>
+ <finalName>${pkg.name}</finalName>
+ <descriptors>
+ <descriptor>src/main/assembly/windows.xml</descriptor>
+ </descriptors>
+ </configuration>
+ <executions>
+ <execution>
+ <id>assembly</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-install-plugin</artifactId>
+ <configuration>
+ <file>${project.build.directory}/${pkg.name}.deb</file>
+ <artifactId>${project.artifactId}</artifactId>
+ <groupId>${project.groupId}</groupId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <packaging>deb</packaging>
+ </configuration>
+ <executions>
+ <execution>
+ <id>install-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>install-file</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
</project>
transport/coap/src/main/assembly/windows.xml 71(+71 -0)
diff --git a/transport/coap/src/main/assembly/windows.xml b/transport/coap/src/main/assembly/windows.xml
new file mode 100644
index 0000000..82da34e
--- /dev/null
+++ b/transport/coap/src/main/assembly/windows.xml
@@ -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.
+
+-->
+<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
+ <id>windows</id>
+
+ <formats>
+ <format>zip</format>
+ </formats>
+
+ <!-- Workaround to create logs directory -->
+ <fileSets>
+ <fileSet>
+ <directory>${pkg.win.dist}</directory>
+ <outputDirectory>logs</outputDirectory>
+ <excludes>
+ <exclude>*/**</exclude>
+ </excludes>
+ </fileSet>
+ <fileSet>
+ <directory>${pkg.win.dist}/conf</directory>
+ <outputDirectory>conf</outputDirectory>
+ <lineEnding>windows</lineEnding>
+ </fileSet>
+ </fileSets>
+
+ <files>
+ <file>
+ <source>${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</source>
+ <outputDirectory>lib</outputDirectory>
+ <destName>${pkg.name}.jar</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.exe</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.xml</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.xml</destName>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/install.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/uninstall.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ </files>
+</assembly>
transport/coap/src/main/conf/logback.xml 43(+43 -0)
diff --git a/transport/coap/src/main/conf/logback.xml b/transport/coap/src/main/conf/logback.xml
new file mode 100644
index 0000000..f36469d
--- /dev/null
+++ b/transport/coap/src/main/conf/logback.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration>
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>${pkg.logFolder}/${pkg.name}.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ </root>
+
+</configuration>
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 0000000..0afa91c
--- /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 0000000..8967278
--- /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 0000000..a6e48d9
--- /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 0000000..1ad86dd
--- /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/resources/logback.xml b/transport/coap/src/main/resources/logback.xml
new file mode 100644
index 0000000..18864a9
--- /dev/null
+++ b/transport/coap/src/main/resources/logback.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="TRACE" />
+
+ <root level="INFO">
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ 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 0000000..95e7b75
--- /dev/null
+++ b/transport/coap/src/main/resources/tb-coap-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-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}"
+ 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}"
+ 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
+ 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 0000000..d4066c0
--- /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 0000000..6186580
--- /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 0000000..6be5959
--- /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 0000000..898d3ef
--- /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 0000000..8a7a88f
--- /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 0000000..8e1f8a2
--- /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 0000000..e19fc88
--- /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 0000000..accb487
--- /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 0000000..d456fc0
--- /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 0000000..dba7736
--- /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 0000000..f7b9d30
--- /dev/null
+++ b/transport/coap/src/main/scripts/windows/service.xml
@@ -0,0 +1,36 @@
+<service>
+ <id>${pkg.name}</id>
+ <name>${project.name}</name>
+ <description>${project.description}</description>
+ <workingdirectory>%BASE%\conf</workingdirectory>
+ <logpath>${pkg.winWrapperLogFolder}</logpath>
+ <logmode>rotate</logmode>
+ <env name="LOADER_PATH" value="%BASE%\conf" />
+ <executable>java</executable>
+ <startargument>-Xloggc:%BASE%\logs\gc.log</startargument>
+ <startargument>-XX:+HeapDumpOnOutOfMemoryError</startargument>
+ <startargument>-XX:+PrintGCDetails</startargument>
+ <startargument>-XX:+PrintGCDateStamps</startargument>
+ <startargument>-XX:+PrintHeapAtGC</startargument>
+ <startargument>-XX:+PrintTenuringDistribution</startargument>
+ <startargument>-XX:+PrintGCApplicationStoppedTime</startargument>
+ <startargument>-XX:+UseGCLogFileRotation</startargument>
+ <startargument>-XX:NumberOfGCLogFiles=10</startargument>
+ <startargument>-XX:GCLogFileSize=10M</startargument>
+ <startargument>-XX:-UseBiasedLocking</startargument>
+ <startargument>-XX:+UseTLAB</startargument>
+ <startargument>-XX:+ResizeTLAB</startargument>
+ <startargument>-XX:+PerfDisableSharedMem</startargument>
+ <startargument>-XX:+UseCondCardMark</startargument>
+ <startargument>-XX:CMSWaitDuration=10000</startargument>
+ <startargument>-XX:+UseParNewGC</startargument>
+ <startargument>-XX:+UseConcMarkSweepGC</startargument>
+ <startargument>-XX:+CMSParallelRemarkEnabled</startargument>
+ <startargument>-XX:+CMSParallelInitialMarkEnabled</startargument>
+ <startargument>-XX:+CMSEdenChunksRecordAlways</startargument>
+ <startargument>-XX:CMSInitiatingOccupancyFraction=75</startargument>
+ <startargument>-XX:+UseCMSInitiatingOccupancyOnly</startargument>
+ <startargument>-jar</startargument>
+ <startargument>%BASE%\lib\${pkg.name}.jar</startargument>
+
+</service>
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 0000000..921e4c8
--- /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
transport/http/build.gradle 140(+140 -0)
diff --git a/transport/http/build.gradle b/transport/http/build.gradle
new file mode 100644
index 0000000..6d54cb4
--- /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")
+}
transport/http/pom.xml 298(+279 -19)
diff --git a/transport/http/pom.xml b/transport/http/pom.xml
index a3117d5..407d995 100644
--- a/transport/http/pom.xml
+++ b/transport/http/pom.xml
@@ -20,46 +20,44 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.transport</groupId>
<artifactId>http</artifactId>
<packaging>jar</packaging>
- <name>Thingsboard HTTP Transport</name>
+ <name>Thingsboard HTTP Transport Service</name>
<url>https://thingsboard.io</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.dir>${basedir}/../..</main.dir>
+ <pkg.name>tb-http-transport</pkg.name>
+ <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ <pkg.win.dist>${project.build.directory}/windows</pkg.win.dist>
</properties>
<dependencies>
<dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>transport</artifactId>
- </dependency>
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-starter-web</artifactId>
- <scope>provided</scope>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>http</artifactId>
</dependency>
<dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>slf4j-api</artifactId>
- </dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>log4j-over-slf4j</artifactId>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
</dependency>
<dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-core</artifactId>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <scope>provided</scope>
</dependency>
<dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -78,4 +76,266 @@
</dependency>
</dependencies>
+ <build>
+ <finalName>${pkg.name}-${project.version}</finalName>
+ <resources>
+ <resource>
+ <directory>${project.basedir}/src/main/resources</directory>
+ </resource>
+ </resources>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <excludes>
+ <exclude>logback.xml</exclude>
+ </excludes>
+ <filtering>false</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-service-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/conf</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-win-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <excludes>
+ <exclude>logback.xml</exclude>
+ </excludes>
+ <filtering>false</filtering>
+ </resource>
+ <resource>
+ <directory>src/main/conf</directory>
+ <excludes>
+ <exclude>tb-http-transport.conf</exclude>
+ </excludes>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/control</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/control</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-windows-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/windows</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-winsw-service</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <destFileName>service.exe</destFileName>
+ </artifactItem>
+ </artifactItems>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <excludes>
+ <exclude>**/logback.xml</exclude>
+ </excludes>
+ <archive>
+ <manifestEntries>
+ <Implementation-Title>ThingsBoard HTTP Transport Service</Implementation-Title>
+ <Implementation-Version>${project.version}</Implementation-Version>
+ </manifestEntries>
+ </archive>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ <configuration>
+ <mainClass>org.thingsboard.server.http.ThingsboardHttpTransportApplication</mainClass>
+ <classifier>boot</classifier>
+ <layout>ZIP</layout>
+ <executable>true</executable>
+ <excludeDevtools>true</excludeDevtools>
+ <embeddedLaunchScriptProperties>
+ <confFolder>${pkg.installFolder}/conf</confFolder>
+ <logFolder>${pkg.unixLogFolder}</logFolder>
+ <logFilename>${pkg.name}.out</logFilename>
+ <initInfoProvides>${pkg.name}</initInfoProvides>
+ </embeddedLaunchScriptProperties>
+ </configuration>
+ <executions>
+ <execution>
+ <goals>
+ <goal>repackage</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.fortasoft</groupId>
+ <artifactId>gradle-maven-plugin</artifactId>
+ <configuration>
+ <tasks>
+ <task>build</task>
+ <task>buildDeb</task>
+ <task>buildRpm</task>
+ </tasks>
+ <args>
+ <arg>-PprojectBuildDir=${project.build.directory}</arg>
+ <arg>-PprojectVersion=${project.version}</arg>
+ <arg>-PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</arg>
+ <arg>-PpkgName=${pkg.name}</arg>
+ <arg>-PpkgInstallFolder=${pkg.installFolder}</arg>
+ <arg>-PpkgLogFolder=${pkg.unixLogFolder}</arg>
+ </args>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>invoke</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <configuration>
+ <finalName>${pkg.name}</finalName>
+ <descriptors>
+ <descriptor>src/main/assembly/windows.xml</descriptor>
+ </descriptors>
+ </configuration>
+ <executions>
+ <execution>
+ <id>assembly</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-install-plugin</artifactId>
+ <configuration>
+ <file>${project.build.directory}/${pkg.name}.deb</file>
+ <artifactId>${project.artifactId}</artifactId>
+ <groupId>${project.groupId}</groupId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <packaging>deb</packaging>
+ </configuration>
+ <executions>
+ <execution>
+ <id>install-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>install-file</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
</project>
transport/http/src/main/assembly/windows.xml 71(+71 -0)
diff --git a/transport/http/src/main/assembly/windows.xml b/transport/http/src/main/assembly/windows.xml
new file mode 100644
index 0000000..82da34e
--- /dev/null
+++ b/transport/http/src/main/assembly/windows.xml
@@ -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.
+
+-->
+<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
+ <id>windows</id>
+
+ <formats>
+ <format>zip</format>
+ </formats>
+
+ <!-- Workaround to create logs directory -->
+ <fileSets>
+ <fileSet>
+ <directory>${pkg.win.dist}</directory>
+ <outputDirectory>logs</outputDirectory>
+ <excludes>
+ <exclude>*/**</exclude>
+ </excludes>
+ </fileSet>
+ <fileSet>
+ <directory>${pkg.win.dist}/conf</directory>
+ <outputDirectory>conf</outputDirectory>
+ <lineEnding>windows</lineEnding>
+ </fileSet>
+ </fileSets>
+
+ <files>
+ <file>
+ <source>${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</source>
+ <outputDirectory>lib</outputDirectory>
+ <destName>${pkg.name}.jar</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.exe</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.xml</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.xml</destName>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/install.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/uninstall.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ </files>
+</assembly>
transport/http/src/main/conf/logback.xml 43(+43 -0)
diff --git a/transport/http/src/main/conf/logback.xml b/transport/http/src/main/conf/logback.xml
new file mode 100644
index 0000000..f36469d
--- /dev/null
+++ b/transport/http/src/main/conf/logback.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration>
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>${pkg.logFolder}/${pkg.name}.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ </root>
+
+</configuration>
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 0000000..0afa91c
--- /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 0000000..8967278
--- /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 0000000..a6e48d9
--- /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/http/ThingsboardHttpTransportApplication.java b/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java
new file mode 100644
index 0000000..517794c
--- /dev/null
+++ b/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.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.http;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+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 0000000..18864a9
--- /dev/null
+++ b/transport/http/src/main/resources/logback.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="TRACE" />
+
+ <root level="INFO">
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ 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 0000000..bb1d3c4
--- /dev/null
+++ b/transport/http/src/main/resources/tb-http-transport.yml
@@ -0,0 +1,58 @@
+#
+# Copyright © 2016-2018 The Thingsboard Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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}"
+ 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}"
+ 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
+ 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 0000000..d4066c0
--- /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 0000000..6186580
--- /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 0000000..6be5959
--- /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 0000000..898d3ef
--- /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 0000000..8a7a88f
--- /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 0000000..8e1f8a2
--- /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 0000000..e19fc88
--- /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 0000000..accb487
--- /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 0000000..d456fc0
--- /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 0000000..dba7736
--- /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 0000000..f7b9d30
--- /dev/null
+++ b/transport/http/src/main/scripts/windows/service.xml
@@ -0,0 +1,36 @@
+<service>
+ <id>${pkg.name}</id>
+ <name>${project.name}</name>
+ <description>${project.description}</description>
+ <workingdirectory>%BASE%\conf</workingdirectory>
+ <logpath>${pkg.winWrapperLogFolder}</logpath>
+ <logmode>rotate</logmode>
+ <env name="LOADER_PATH" value="%BASE%\conf" />
+ <executable>java</executable>
+ <startargument>-Xloggc:%BASE%\logs\gc.log</startargument>
+ <startargument>-XX:+HeapDumpOnOutOfMemoryError</startargument>
+ <startargument>-XX:+PrintGCDetails</startargument>
+ <startargument>-XX:+PrintGCDateStamps</startargument>
+ <startargument>-XX:+PrintHeapAtGC</startargument>
+ <startargument>-XX:+PrintTenuringDistribution</startargument>
+ <startargument>-XX:+PrintGCApplicationStoppedTime</startargument>
+ <startargument>-XX:+UseGCLogFileRotation</startargument>
+ <startargument>-XX:NumberOfGCLogFiles=10</startargument>
+ <startargument>-XX:GCLogFileSize=10M</startargument>
+ <startargument>-XX:-UseBiasedLocking</startargument>
+ <startargument>-XX:+UseTLAB</startargument>
+ <startargument>-XX:+ResizeTLAB</startargument>
+ <startargument>-XX:+PerfDisableSharedMem</startargument>
+ <startargument>-XX:+UseCondCardMark</startargument>
+ <startargument>-XX:CMSWaitDuration=10000</startargument>
+ <startargument>-XX:+UseParNewGC</startargument>
+ <startargument>-XX:+UseConcMarkSweepGC</startargument>
+ <startargument>-XX:+CMSParallelRemarkEnabled</startargument>
+ <startargument>-XX:+CMSParallelInitialMarkEnabled</startargument>
+ <startargument>-XX:+CMSEdenChunksRecordAlways</startargument>
+ <startargument>-XX:CMSInitiatingOccupancyFraction=75</startargument>
+ <startargument>-XX:+UseCMSInitiatingOccupancyOnly</startargument>
+ <startargument>-jar</startargument>
+ <startargument>%BASE%\lib\${pkg.name}.jar</startargument>
+
+</service>
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 0000000..921e4c8
--- /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
transport/mqtt/build.gradle 140(+140 -0)
diff --git a/transport/mqtt/build.gradle b/transport/mqtt/build.gradle
new file mode 100644
index 0000000..6d54cb4
--- /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")
+}
transport/mqtt/pom.xml 305(+279 -26)
diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml
index 798e7ab..6395b40 100644
--- a/transport/mqtt/pom.xml
+++ b/transport/mqtt/pom.xml
@@ -20,53 +20,44 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>transport</artifactId>
</parent>
<groupId>org.thingsboard.transport</groupId>
<artifactId>mqtt</artifactId>
<packaging>jar</packaging>
- <name>Thingsboard MQTT Transport</name>
+ <name>Thingsboard MQTT Transport Service</name>
<url>https://thingsboard.io</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<main.dir>${basedir}/../..</main.dir>
+ <pkg.name>tb-mqtt-transport</pkg.name>
+ <pkg.unixLogFolder>/var/log/${pkg.name}</pkg.unixLogFolder>
+ <pkg.installFolder>/usr/share/${pkg.name}</pkg.installFolder>
+ <pkg.win.dist>${project.build.directory}/windows</pkg.win.dist>
</properties>
<dependencies>
<dependency>
- <groupId>org.thingsboard.common</groupId>
- <artifactId>transport</artifactId>
- </dependency>
- <dependency>
- <groupId>io.netty</groupId>
- <artifactId>netty-all</artifactId>
+ <groupId>org.thingsboard.common.transport</groupId>
+ <artifactId>mqtt</artifactId>
</dependency>
<dependency>
- <groupId>org.springframework</groupId>
- <artifactId>spring-context</artifactId>
- </dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>slf4j-api</artifactId>
- </dependency>
- <dependency>
- <groupId>org.slf4j</groupId>
- <artifactId>log4j-over-slf4j</artifactId>
- </dependency>
- <dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-core</artifactId>
+ <groupId>org.thingsboard.common</groupId>
+ <artifactId>queue</artifactId>
</dependency>
<dependency>
- <groupId>ch.qos.logback</groupId>
- <artifactId>logback-classic</artifactId>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
- <groupId>com.google.guava</groupId>
- <artifactId>guava</artifactId>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -85,4 +76,266 @@
</dependency>
</dependencies>
+ <build>
+ <finalName>${pkg.name}-${project.version}</finalName>
+ <resources>
+ <resource>
+ <directory>${project.basedir}/src/main/resources</directory>
+ </resource>
+ </resources>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-resources-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <excludes>
+ <exclude>logback.xml</exclude>
+ </excludes>
+ <filtering>false</filtering>
+ </resource>
+ </resources>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-service-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/conf</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-win-conf</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}/conf</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/resources</directory>
+ <excludes>
+ <exclude>logback.xml</exclude>
+ </excludes>
+ <filtering>false</filtering>
+ </resource>
+ <resource>
+ <directory>src/main/conf</directory>
+ <excludes>
+ <exclude>tb-mqtt-transport.conf</exclude>
+ </excludes>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${project.build.directory}/control</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/control</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/unix.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ <execution>
+ <id>copy-windows-control</id>
+ <phase>process-resources</phase>
+ <goals>
+ <goal>copy-resources</goal>
+ </goals>
+ <configuration>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ <resources>
+ <resource>
+ <directory>src/main/scripts/windows</directory>
+ <filtering>true</filtering>
+ </resource>
+ </resources>
+ <filters>
+ <filter>src/main/filters/windows.properties</filter>
+ </filters>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-dependency-plugin</artifactId>
+ <executions>
+ <execution>
+ <id>copy-winsw-service</id>
+ <phase>package</phase>
+ <goals>
+ <goal>copy</goal>
+ </goals>
+ <configuration>
+ <artifactItems>
+ <artifactItem>
+ <groupId>com.sun.winsw</groupId>
+ <artifactId>winsw</artifactId>
+ <classifier>bin</classifier>
+ <type>exe</type>
+ <destFileName>service.exe</destFileName>
+ </artifactItem>
+ </artifactItems>
+ <outputDirectory>${pkg.win.dist}</outputDirectory>
+ </configuration>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <configuration>
+ <excludes>
+ <exclude>**/logback.xml</exclude>
+ </excludes>
+ <archive>
+ <manifestEntries>
+ <Implementation-Title>ThingsBoard MQTT Transport Service</Implementation-Title>
+ <Implementation-Version>${project.version}</Implementation-Version>
+ </manifestEntries>
+ </archive>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ <configuration>
+ <mainClass>org.thingsboard.server.mqtt.ThingsboardMqttTransportApplication</mainClass>
+ <classifier>boot</classifier>
+ <layout>ZIP</layout>
+ <executable>true</executable>
+ <excludeDevtools>true</excludeDevtools>
+ <embeddedLaunchScriptProperties>
+ <confFolder>${pkg.installFolder}/conf</confFolder>
+ <logFolder>${pkg.unixLogFolder}</logFolder>
+ <logFilename>${pkg.name}.out</logFilename>
+ <initInfoProvides>${pkg.name}</initInfoProvides>
+ </embeddedLaunchScriptProperties>
+ </configuration>
+ <executions>
+ <execution>
+ <goals>
+ <goal>repackage</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.fortasoft</groupId>
+ <artifactId>gradle-maven-plugin</artifactId>
+ <configuration>
+ <tasks>
+ <task>build</task>
+ <task>buildDeb</task>
+ <task>buildRpm</task>
+ </tasks>
+ <args>
+ <arg>-PprojectBuildDir=${project.build.directory}</arg>
+ <arg>-PprojectVersion=${project.version}</arg>
+ <arg>-PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</arg>
+ <arg>-PpkgName=${pkg.name}</arg>
+ <arg>-PpkgInstallFolder=${pkg.installFolder}</arg>
+ <arg>-PpkgLogFolder=${pkg.unixLogFolder}</arg>
+ </args>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>invoke</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-assembly-plugin</artifactId>
+ <configuration>
+ <finalName>${pkg.name}</finalName>
+ <descriptors>
+ <descriptor>src/main/assembly/windows.xml</descriptor>
+ </descriptors>
+ </configuration>
+ <executions>
+ <execution>
+ <id>assembly</id>
+ <phase>package</phase>
+ <goals>
+ <goal>single</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-install-plugin</artifactId>
+ <configuration>
+ <file>${project.build.directory}/${pkg.name}.deb</file>
+ <artifactId>${project.artifactId}</artifactId>
+ <groupId>${project.groupId}</groupId>
+ <version>${project.version}</version>
+ <classifier>deb</classifier>
+ <packaging>deb</packaging>
+ </configuration>
+ <executions>
+ <execution>
+ <id>install-deb</id>
+ <phase>package</phase>
+ <goals>
+ <goal>install-file</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ <repositories>
+ <repository>
+ <id>jenkins</id>
+ <name>Jenkins Repository</name>
+ <url>http://repo.jenkins-ci.org/releases</url>
+ <snapshots>
+ <enabled>false</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
</project>
transport/mqtt/src/main/assembly/windows.xml 71(+71 -0)
diff --git a/transport/mqtt/src/main/assembly/windows.xml b/transport/mqtt/src/main/assembly/windows.xml
new file mode 100644
index 0000000..82da34e
--- /dev/null
+++ b/transport/mqtt/src/main/assembly/windows.xml
@@ -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.
+
+-->
+<assembly xmlns="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/plugins/maven-assembly-plugin/assembly/1.1.3 http://maven.apache.org/xsd/assembly-1.1.3.xsd">
+ <id>windows</id>
+
+ <formats>
+ <format>zip</format>
+ </formats>
+
+ <!-- Workaround to create logs directory -->
+ <fileSets>
+ <fileSet>
+ <directory>${pkg.win.dist}</directory>
+ <outputDirectory>logs</outputDirectory>
+ <excludes>
+ <exclude>*/**</exclude>
+ </excludes>
+ </fileSet>
+ <fileSet>
+ <directory>${pkg.win.dist}/conf</directory>
+ <outputDirectory>conf</outputDirectory>
+ <lineEnding>windows</lineEnding>
+ </fileSet>
+ </fileSets>
+
+ <files>
+ <file>
+ <source>${project.build.directory}/${project.build.finalName}-boot.${project.packaging}</source>
+ <outputDirectory>lib</outputDirectory>
+ <destName>${pkg.name}.jar</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.exe</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.exe</destName>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/service.xml</source>
+ <outputDirectory/>
+ <destName>${pkg.name}.xml</destName>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/install.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ <file>
+ <source>${pkg.win.dist}/uninstall.bat</source>
+ <outputDirectory/>
+ <lineEnding>windows</lineEnding>
+ </file>
+ </files>
+</assembly>
transport/mqtt/src/main/conf/logback.xml 43(+43 -0)
diff --git a/transport/mqtt/src/main/conf/logback.xml b/transport/mqtt/src/main/conf/logback.xml
new file mode 100644
index 0000000..f36469d
--- /dev/null
+++ b/transport/mqtt/src/main/conf/logback.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration>
+
+ <appender name="fileLogAppender"
+ class="ch.qos.logback.core.rolling.RollingFileAppender">
+ <file>${pkg.logFolder}/${pkg.name}.log</file>
+ <rollingPolicy
+ class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
+ <fileNamePattern>${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+ <maxFileSize>100MB</maxFileSize>
+ <maxHistory>30</maxHistory>
+ <totalSizeCap>3GB</totalSizeCap>
+ </rollingPolicy>
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="INFO" />
+
+ <root level="INFO">
+ <appender-ref ref="fileLogAppender"/>
+ </root>
+
+</configuration>
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 0000000..0afa91c
--- /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 0000000..8967278
--- /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 0000000..a6e48d9
--- /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/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java
new file mode 100644
index 0000000..2bae2ff
--- /dev/null
+++ b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.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.mqtt;
+
+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.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";
+ 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/src/main/resources/logback.xml b/transport/mqtt/src/main/resources/logback.xml
new file mode 100644
index 0000000..18864a9
--- /dev/null
+++ b/transport/mqtt/src/main/resources/logback.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<!DOCTYPE configuration>
+<configuration scan="true" scanPeriod="10 seconds">
+
+ <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+ <encoder>
+ <pattern>%d{ISO8601} [%thread] %-5level %logger{36} - %msg%n</pattern>
+ </encoder>
+ </appender>
+
+ <logger name="org.thingsboard.server" level="TRACE" />
+
+ <root level="INFO">
+ <appender-ref ref="STDOUT"/>
+ </root>
+
+</configuration>
\ 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
new file mode 100644
index 0000000..719530c
--- /dev/null
+++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml
@@ -0,0 +1,77 @@
+#
+# Copyright © 2016-2018 The Thingsboard Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT 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:
+ mqtt:
+ 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_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}"
+ # 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}"
+ 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}"
+ 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
+ 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/mqtt/src/main/scripts/control/deb/postinst b/transport/mqtt/src/main/scripts/control/deb/postinst
new file mode 100644
index 0000000..d4066c0
--- /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 0000000..6186580
--- /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 0000000..6be5959
--- /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 0000000..898d3ef
--- /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 0000000..8a7a88f
--- /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 0000000..8e1f8a2
--- /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 0000000..e19fc88
--- /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 0000000..accb487
--- /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 0000000..d456fc0
--- /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 0000000..dba7736
--- /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 0000000..f7b9d30
--- /dev/null
+++ b/transport/mqtt/src/main/scripts/windows/service.xml
@@ -0,0 +1,36 @@
+<service>
+ <id>${pkg.name}</id>
+ <name>${project.name}</name>
+ <description>${project.description}</description>
+ <workingdirectory>%BASE%\conf</workingdirectory>
+ <logpath>${pkg.winWrapperLogFolder}</logpath>
+ <logmode>rotate</logmode>
+ <env name="LOADER_PATH" value="%BASE%\conf" />
+ <executable>java</executable>
+ <startargument>-Xloggc:%BASE%\logs\gc.log</startargument>
+ <startargument>-XX:+HeapDumpOnOutOfMemoryError</startargument>
+ <startargument>-XX:+PrintGCDetails</startargument>
+ <startargument>-XX:+PrintGCDateStamps</startargument>
+ <startargument>-XX:+PrintHeapAtGC</startargument>
+ <startargument>-XX:+PrintTenuringDistribution</startargument>
+ <startargument>-XX:+PrintGCApplicationStoppedTime</startargument>
+ <startargument>-XX:+UseGCLogFileRotation</startargument>
+ <startargument>-XX:NumberOfGCLogFiles=10</startargument>
+ <startargument>-XX:GCLogFileSize=10M</startargument>
+ <startargument>-XX:-UseBiasedLocking</startargument>
+ <startargument>-XX:+UseTLAB</startargument>
+ <startargument>-XX:+ResizeTLAB</startargument>
+ <startargument>-XX:+PerfDisableSharedMem</startargument>
+ <startargument>-XX:+UseCondCardMark</startargument>
+ <startargument>-XX:CMSWaitDuration=10000</startargument>
+ <startargument>-XX:+UseParNewGC</startargument>
+ <startargument>-XX:+UseConcMarkSweepGC</startargument>
+ <startargument>-XX:+CMSParallelRemarkEnabled</startargument>
+ <startargument>-XX:+CMSParallelInitialMarkEnabled</startargument>
+ <startargument>-XX:+CMSEdenChunksRecordAlways</startargument>
+ <startargument>-XX:CMSInitiatingOccupancyFraction=75</startargument>
+ <startargument>-XX:+UseCMSInitiatingOccupancyOnly</startargument>
+ <startargument>-jar</startargument>
+ <startargument>%BASE%\lib\${pkg.name}.jar</startargument>
+
+</service>
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 0000000..921e4c8
--- /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
transport/pom.xml 20(+2 -18)
diff --git a/transport/pom.xml b/transport/pom.xml
index df2bd27..0bb9300 100644
--- a/transport/pom.xml
+++ b/transport/pom.xml
@@ -20,10 +20,9 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
- <groupId>org.thingsboard</groupId>
<artifactId>transport</artifactId>
<packaging>pom</packaging>
@@ -36,23 +35,8 @@
<modules>
<module>http</module>
- <module>coap</module>
<module>mqtt</module>
+ <module>coap</module>
</modules>
- <dependencies>
- <dependency>
- <groupId>org.thingsboard</groupId>
- <artifactId>dao</artifactId>
- </dependency>
- <dependency>
- <groupId>org.springframework.boot</groupId>
- <artifactId>spring-boot-autoconfigure</artifactId>
- </dependency>
- <dependency>
- <groupId>org.bouncycastle</groupId>
- <artifactId>bcprov-jdk15on</artifactId>
- </dependency>
- </dependencies>
-
</project>
ui/.stylelintrc 6(+3 -3)
diff --git a/ui/.stylelintrc b/ui/.stylelintrc
index c145c8b..e878729 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
}
}
ui/package.json 24(+19 -5)
diff --git a/ui/package.json b/ui/package.json
index 498de06..9fb0cc5 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -1,8 +1,8 @@
{
"name": "thingsboard",
"private": true,
- "version": "2.1.1",
- "description": "Thingsboard UI",
+ "version": "2.2.0",
+ "description": "ThingsBoard UI",
"licenses": [
{
"type": "Apache-2.0",
@@ -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",
@@ -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
+ }
}
}
ui/pom.xml 2(+1 -1)
diff --git a/ui/pom.xml b/ui/pom.xml
index 92c5a60..52c309c 100644
--- a/ui/pom.xml
+++ b/ui/pom.xml
@@ -20,7 +20,7 @@
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.thingsboard</groupId>
- <version>2.1.1-SNAPSHOT</version>
+ <version>2.2.0-SNAPSHOT</version>
<artifactId>thingsboard</artifactId>
</parent>
<groupId>org.thingsboard</groupId>
diff --git a/ui/src/app/alarm/alarm-row.directive.js b/ui/src/app/alarm/alarm-row.directive.js
index 37eec7c..681498f 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);
}
ui/src/app/api/datasource.service.js 18(+15 -3)
diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js
index dd1a34f..2792151 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);
ui/src/app/api/entity.service.js 54(+52 -2)
diff --git a/ui/src/app/api/entity.service.js b/ui/src/app/api/entity.service.js
index 2e29238..00c7ecf 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;
@@ -131,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);
@@ -239,6 +247,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);
@@ -522,6 +537,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;
@@ -567,6 +597,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;
@@ -593,6 +624,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) {
@@ -635,6 +669,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;
@@ -660,6 +696,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;
@@ -679,12 +717,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;
}
@@ -725,6 +767,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 +778,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) {
@@ -1033,6 +1077,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);
}
}
@@ -1138,6 +1184,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);
}
}
@@ -1266,6 +1314,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
}
ui/src/app/api/entity-view.service.js 211(+211 -0)
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 0000000..23fefbc
--- /dev/null
+++ b/ui/src/app/api/entity-view.service.js
@@ -0,0 +1,211 @@
+/*
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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,
+ 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 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;
+ }
+
+}
ui/src/app/api/subscription.js 16(+12 -4)
diff --git a/ui/src/app/api/subscription.js b/ui/src/app/api/subscription.js
index 350b1ef..4a38f71 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;
}
@@ -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() {
ui/src/app/api/time.service.js 53(+39 -14)
diff --git a/ui/src/app/api/time.service.js b/ui/src/app/api/time.service.js
index b8d0c41..56447aa 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)) {
ui/src/app/api/user.service.js 3(+2 -1)
diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js
index fa4d63c..a49c4a0 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);
}
ui/src/app/app.js 34(+20 -14)
diff --git a/ui/src/app/app.js b/ui/src/app/app.js
index c8cdeb0..31b53a0 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';
@@ -51,6 +52,21 @@ 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 thingsboardThirdpartyFix from './common/thirdparty-fix';
import thingsboardTranslateHandler from './locale/translate-handler';
import thingsboardLogin from './login';
import thingsboardDialogs from './components/datakey-config-dialog.controller';
@@ -67,6 +83,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';
@@ -77,20 +94,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';
@@ -104,6 +107,7 @@ angular.module('thingsboard', [
'mdColorPicker',
mdPickers,
ngSanitize,
+ FBAngular.name,
vAccordion,
ngAnimate,
'ngWebSocket',
@@ -117,6 +121,7 @@ angular.module('thingsboard', [
react.name,
'flow',
'flowchart',
+ thingsboardThirdpartyFix,
thingsboardTranslateHandler,
thingsboardLogin,
thingsboardDialogs,
@@ -133,6 +138,7 @@ angular.module('thingsboard', [
thingsboardHome,
thingsboardApiLogin,
thingsboardApiDevice,
+ thingsboardApiEntityView,
thingsboardApiUser,
thingsboardApiEntityRelation,
thingsboardApiAsset,
diff --git a/ui/src/app/audit/audit-log-row.directive.js b/ui/src/app/audit/audit-log-row.directive.js
index 2c2e170..f13a0d2 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);
}
ui/src/app/common/thirdparty-fix.js 459(+459 -0)
diff --git a/ui/src/app/common/thirdparty-fix.js b/ui/src/app/common/thirdparty-fix.js
new file mode 100644
index 0000000..af4222c
--- /dev/null
+++ b/ui/src/app/common/thirdparty-fix.js
@@ -0,0 +1,459 @@
+/*
+ * Copyright © 2016-2018 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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';
+import moment from 'moment';
+
+export default angular.module('thingsboard.thirdpartyFix', [])
+ .factory('Fullscreen', Fullscreen)
+ .factory('$mdColorPicker', mdColorPicker)
+ .provider('$mdpDatePicker', mdpDatePicker)
+ .provider('$mdpTimePicker', mdpTimePicker)
+ .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 */
+}
+
+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: '<md-dialog aria-label="" class="mdp-datepicker" ng-class="{ \'portrait\': !$mdMedia(\'gt-xs\') }">' +
+ '<md-dialog-content layout="row" layout-wrap>' +
+ '<div layout="column" layout-align="start center">' +
+ '<md-toolbar layout-align="start start" flex class="mdp-datepicker-date-wrapper md-hue-1 md-primary" layout="column">' +
+ '<span class="mdp-datepicker-year" ng-click="datepicker.showYear()" ng-class="{ \'active\': datepicker.selectingYear }">{{ datepicker.date.format(\'YYYY\') }}</span>' +
+ '<span class="mdp-datepicker-date" ng-click="datepicker.showCalendar()" ng-class="{ \'active\': !datepicker.selectingYear }">{{ datepicker.date.format(datepicker.displayFormat) }}</span> ' +
+ '</md-toolbar>' +
+ '</div>' +
+ '<div>' +
+ '<div class="mdp-datepicker-select-year mdp-animation-zoom" layout="column" layout-align="center start" ng-if="datepicker.selectingYear">' +
+ '<md-virtual-repeat-container md-auto-shrink md-top-index="datepicker.yearTopIndex">' +
+ '<div flex md-virtual-repeat="item in datepicker.yearItems" md-on-demand class="repeated-year">' +
+ '<span class="md-button" ng-click="datepicker.selectYear(item)" md-ink-ripple ng-class="{ \'md-primary current\': item == year }">{{ item }}</span>' +
+ '</div>' +
+ '</md-virtual-repeat-container>' +
+ '</div>' +
+ '<mdp-calendar ng-if="!datepicker.selectingYear" class="mdp-animation-zoom" date="datepicker.date" min-date="datepicker.minDate" date-filter="datepicker.dateFilter" max-date="datepicker.maxDate"></mdp-calendar>' +
+ '<md-dialog-actions layout="row">' +
+ '<span flex></span>' +
+ '<md-button ng-click="datepicker.cancel()" aria-label="' + LABEL_CANCEL + '">' + LABEL_CANCEL + '</md-button>' +
+ '<md-button ng-click="datepicker.confirm()" class="md-primary" aria-label="' + LABEL_OK + '">' + LABEL_OK + '</md-button>' +
+ '</md-dialog-actions>' +
+ '</div>' +
+ '</md-dialog-content>' +
+ '</md-dialog>',
+ 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: '<md-dialog aria-label="" class="mdp-timepicker" ng-class="{ \'portrait\': !$mdMedia(\'gt-xs\') }">' +
+ '<md-dialog-content layout-gt-xs="row" layout-wrap>' +
+ '<md-toolbar layout-gt-xs="column" layout-xs="row" layout-align="center center" flex class="mdp-timepicker-time md-hue-1 md-primary">' +
+ '<div class="mdp-timepicker-selected-time">' +
+ '<span ng-class="{ \'active\': timepicker.currentView == timepicker.VIEW_HOURS }" ng-click="timepicker.currentView = timepicker.VIEW_HOURS">{{ timepicker.time.format("h") }}</span>:' +
+ '<span ng-class="{ \'active\': timepicker.currentView == timepicker.VIEW_MINUTES }" ng-click="timepicker.currentView = timepicker.VIEW_MINUTES">{{ timepicker.time.format("mm") }}</span>' +
+ '</div>' +
+ '<div layout="column" class="mdp-timepicker-selected-ampm">' +
+ '<span ng-click="timepicker.setAM()" ng-class="{ \'active\': timepicker.time.hours() < 12 }">AM</span>' +
+ '<span ng-click="timepicker.setPM()" ng-class="{ \'active\': timepicker.time.hours() >= 12 }">PM</span>' +
+ '</div>' +
+ '</md-toolbar>' +
+ '<div>' +
+ '<div class="mdp-clock-switch-container" ng-switch="timepicker.currentView" layout layout-align="center center">' +
+ '<mdp-clock class="mdp-animation-zoom" auto-switch="timepicker.autoSwitch" time="timepicker.time" type="hours" ng-switch-when="1"></mdp-clock>' +
+ '<mdp-clock class="mdp-animation-zoom" auto-switch="timepicker.autoSwitch" time="timepicker.time" type="minutes" ng-switch-when="2"></mdp-clock>' +
+ '</div>' +
+
+ '<md-dialog-actions layout="row">' +
+ '<span flex></span>' +
+ '<md-button ng-click="timepicker.cancel()" aria-label="' + LABEL_CANCEL + '">' + LABEL_CANCEL + '</md-button>' +
+ '<md-button ng-click="timepicker.confirm()" class="md-primary" aria-label="' + LABEL_OK + '">' + LABEL_OK + '</md-button>' +
+ '</md-dialog-actions>' +
+ '</div>' +
+ '</md-dialog-content>' +
+ '</md-dialog>',
+ targetEvent: options.targetEvent,
+ locals: {
+ time: time,
+ autoSwitch: options.autoSwitch
+ },
+ multiple: true
+ });
+ };
+
+ return timePicker;
+ };
+}
ui/src/app/common/types.constant.js 17(+16 -1)
diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js
index 1e34577..506ef1d 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: {
@@ -327,7 +335,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"
@@ -345,6 +354,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',
ui/src/app/components/dashboard.scss 4(+2 -2)
diff --git a/ui/src/app/components/dashboard.scss b/ui/src/app/components/dashboard.scss
index e0a9afd..b4a696a 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/datakey-config.tpl.html b/ui/src/app/components/datakey-config.tpl.html
index f9126e6..ff94f1a 100644
--- a/ui/src/app/components/datakey-config.tpl.html
+++ b/ui/src/app/components/datakey-config.tpl.html
@@ -75,9 +75,16 @@
</md-checkbox>
<tb-js-func ng-if="model.usePostProcessing"
ng-model="model.postFuncBody"
- function-args="{{ ['time', 'value', 'prevValue'] }}"
- validation-args="{{ [[1, 1, 1],[1, '1', '1']] }}"
+ function-args="{{ ['time', 'value', 'prevValue', 'timePrev', 'prevOrigValue'] }}"
+ validation-args="{{ [[1, 1, 1, 1, 1],[1, '1', '1', 1, '1']] }}"
result-type="any">
</tb-js-func>
+ <label ng-if="model.usePostProcessing" class="tb-title" style="margin-left: 15px;">
+ time - {{ 'datakey.time-description' | translate }}</br>
+ value - {{ 'datakey.value-description' | translate }}</br>
+ prevValue - {{ 'datakey.prev-value-description' | translate }}</br>
+ timePrev - {{ 'datakey.time-prev-description' | translate }}</br>
+ prevOrigValue - {{ 'datakey.prev-orig-value-description' | translate }}
+ </label>
</section>
</md-content>
\ No newline at end of file
diff --git a/ui/src/app/components/datasource-entity.directive.js b/ui/src/app/components/datasource-entity.directive.js
index b02a30d..6bc1932 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 db6fd3b..8f25787 100644
--- a/ui/src/app/components/datasource-entity.tpl.html
+++ b/ui/src/app/components/datasource-entity.tpl.html
@@ -26,7 +26,6 @@
<section flex layout='column' layout-align="center" style="padding-left: 4px;">
<md-chips flex ng-if="widgetType != types.widgetType.alarm.value"
id="timeseries_datakey_chips"
- ng-required="true"
ng-model="timeseriesDataKeys" md-autocomplete-snap
md-transform-chip="transformTimeseriesDataKeyChip($chip)"
md-require-match="false">
@@ -78,7 +77,6 @@
</md-chips>
<md-chips flex ng-if="widgetType === types.widgetType.latest.value"
id="attribute_datakey_chips"
- ng-required="true"
ng-model="attributeDataKeys" md-autocomplete-snap
md-transform-chip="transformAttributeDataKeyChip($chip)"
md-require-match="false">
diff --git a/ui/src/app/components/datasource-func.directive.js b/ui/src/app/components/datasource-func.directive.js
index 7515b61..982685f 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/datetime-period.tpl.html b/ui/src/app/components/datetime-period.tpl.html
index 605c3a8..7479576 100644
--- a/ui/src/app/components/datetime-period.tpl.html
+++ b/ui/src/app/components/datetime-period.tpl.html
@@ -18,14 +18,14 @@
<section layout="column" layout-align="start start">
<section layout="row" layout-align="start start">
<mdp-date-picker ng-model="startDate" mdp-placeholder="{{ 'datetime.date-from' | translate }}"
- mdp-max-date="maxStartDate"></mdp-date-picker>
+ ></mdp-date-picker>
<mdp-time-picker ng-model="startDate" mdp-placeholder="{{ 'datetime.time-from' | translate }}"
- mdp-max-date="maxStartDate" mdp-auto-switch="true"></mdp-time-picker>
+ mdp-auto-switch="true"></mdp-time-picker>
</section>
<section layout="row" layout-align="start start">
<mdp-date-picker ng-model="endDate" mdp-placeholder="{{ 'datetime.date-to' | translate }}"
- mdp-min-date="minEndDate" mdp-max-date="maxEndDate"></mdp-date-picker>
+ ></mdp-date-picker>
<mdp-time-picker ng-model="endDate" mdp-placeholder="{{ 'datetime.time-to' | translate }}"
- mdp-min-date="minEndDate" mdp-max-date="maxEndDate" mdp-auto-switch="true"></mdp-time-picker>
+ mdp-auto-switch="true"></mdp-time-picker>
</section>
</section>
\ No newline at end of file
ui/src/app/components/grid.scss 4(+2 -2)
diff --git a/ui/src/app/components/grid.scss b/ui/src/app/components/grid.scss
index 4313848..50e110b 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/json-form.directive.js b/ui/src/app/components/json-form.directive.js
index b016f57..97b7bfb 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 67caff2..f3efe63 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) {
ui/src/app/components/menu-link.scss 6(+3 -3)
diff --git a/ui/src/app/components/menu-link.scss b/ui/src/app/components/menu-link.scss
index 299797c..be274d3 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 cca9d07..d324abe 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);
ui/src/app/components/side-menu.scss 2(+1 -1)
diff --git a/ui/src/app/components/side-menu.scss b/ui/src/app/components/side-menu.scss
index 46da8db..f6efcb9 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/components/timewindow.directive.js b/ui/src/app/components/timewindow.directive.js
index 6513671..4a6b72e 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();
diff --git a/ui/src/app/components/timewindow-panel.controller.js b/ui/src/app/components/timewindow-panel.controller.js
index 3cf368b..d48355c 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 271ac4c..50888ac 100644
--- a/ui/src/app/components/timewindow-panel.tpl.html
+++ b/ui/src/app/components/timewindow-panel.tpl.html
@@ -60,19 +60,22 @@
</md-option>
</md-select>
</md-input-container>
- <md-slider-container ng-show="vm.showLimit()">
+ <md-slider-container ng-if="vm.showLimit()">
<span translate>aggregation.limit</span>
- <md-slider flex min="10" max="500" ng-model="vm.timewindow.aggregation.limit" aria-label="limit" id="limit-slider">
+ <md-slider flex min="{{vm.minDatapointsLimit()}}" max="{{vm.maxDatapointsLimit()}}" step="1"
+ ng-model="vm.timewindow.aggregation.limit" aria-label="limit" id="limit-slider">
</md-slider>
- <md-input-container>
- <input flex type="number" ng-model="vm.timewindow.aggregation.limit" aria-label="limit" aria-controls="limit-slider">
+ <md-input-container style="max-width: 80px;">
+ <input flex type="number" step="1"
+ min="{{vm.minDatapointsLimit()}}" max="{{vm.maxDatapointsLimit()}}"
+ ng-model="vm.timewindow.aggregation.limit" aria-label="limit" aria-controls="limit-slider">
</md-input-container>
</md-slider-container>
- <tb-timeinterval ng-show="vm.showRealtimeAggInterval()" min="vm.minRealtimeAggInterval()" max="vm.maxRealtimeAggInterval()"
+ <tb-timeinterval ng-if="vm.showRealtimeAggInterval()" min="vm.minRealtimeAggInterval()" max="vm.maxRealtimeAggInterval()"
predefined-name="'aggregation.group-interval'"
ng-model="vm.timewindow.realtime.interval">
</tb-timeinterval>
- <tb-timeinterval ng-show="vm.showHistoryAggInterval()" min="vm.minHistoryAggInterval()" max="vm.maxHistoryAggInterval()"
+ <tb-timeinterval ng-if="vm.showHistoryAggInterval()" min="vm.minHistoryAggInterval()" max="vm.maxHistoryAggInterval()"
predefined-name="'aggregation.group-interval'"
ng-model="vm.timewindow.history.interval">
</tb-timeinterval>
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 88a37a8..9597204 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) {
@@ -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);
diff --git a/ui/src/app/dashboard/add-widget.controller.js b/ui/src/app/dashboard/add-widget.controller.js
index caa69d6..fd665fd 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 6df48df..3f32e1b 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);
ui/src/app/dashboard/dashboard.scss 10(+5 -5)
diff --git a/ui/src/app/dashboard/dashboard.scss b/ui/src/app/dashboard/dashboard.scss
index 00a0653..715c4da 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 {
@@ -133,7 +133,7 @@ section.tb-powered-by-footer {
position: absolute;
right: 25px;
bottom: 5px;
- z-index: 3;
+ z-index: 30;
pointer-events: none;
span {
diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html
index 829f174..a1bb268 100644
--- a/ui/src/app/dashboard/dashboard.tpl.html
+++ b/ui/src/app/dashboard/dashboard.tpl.html
@@ -74,6 +74,7 @@
<md-icon aria-label="{{ 'dashboard.settings' | translate }}" class="material-icons">settings</md-icon>
</md-button>
<tb-dashboard-select ng-show="!vm.isEdit && !vm.widgetEditMode && vm.displayDashboardsSelect()"
+ md-theme="tb-dark"
ng-model="vm.currentDashboardId"
dashboards-scope="{{vm.currentDashboardScope}}"
customer-id="vm.currentCustomerId">
@@ -146,7 +147,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/app/dashboard/dashboard-card.scss b/ui/src/app/dashboard/dashboard-card.scss
index 8e6626c..111d67a 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/app/dashboard/dashboard-toolbar.scss b/ui/src/app/dashboard/dashboard-toolbar.scss
index 80fd876..c247634 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/edit-widget.directive.js b/ui/src/app/dashboard/edit-widget.directive.js
index e6858b4..f8aee69 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 5ce8a6c..828bd6f 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 98df466..298b2ab 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 732f10f..49f28c9 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 5346e03..f39593c 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/attribute/attribute-table.directive.js b/ui/src/app/entity/attribute/attribute-table.directive.js
index 0061854..3a7c164 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 {
diff --git a/ui/src/app/entity/entity-autocomplete.directive.js b/ui/src/app/entity/entity-autocomplete.directive.js
index e46c614..8c1fed7 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/entity/entity-filter.directive.js b/ui/src/app/entity/entity-filter.directive.js
index 0c8f646..38641fb 100644
--- a/ui/src/app/entity/entity-filter.directive.js
+++ b/ui/src/app/entity/entity-filter.directive.js
@@ -69,9 +69,14 @@ export default function EntityFilterDirective($compile, $templateCache, $q, $doc
filter.deviceType = null;
filter.deviceNameFilter = '';
break;
+ case types.aliasFilterType.entityViewType.value:
+ filter.entityViewType = null;
+ filter.entityViewNameFilter = '';
+ break;
case types.aliasFilterType.relationsQuery.value:
case types.aliasFilterType.assetSearchQuery.value:
case types.aliasFilterType.deviceSearchQuery.value:
+ case types.aliasFilterType.entityViewSearchQuery.value:
filter.rootStateEntity = false;
filter.stateEntityParamName = null;
filter.defaultStateEntity = null;
@@ -86,6 +91,9 @@ export default function EntityFilterDirective($compile, $templateCache, $q, $doc
} else if (filter.type === types.aliasFilterType.deviceSearchQuery.value) {
filter.relationType = null;
filter.deviceTypes = [];
+ } else if (filter.type === types.aliasFilterType.entityViewSearchQuery.value) {
+ filter.relationType = null;
+ filter.entityViewTypes = [];
}
break;
}
ui/src/app/entity/entity-filter.tpl.html 83(+83 -0)
diff --git a/ui/src/app/entity/entity-filter.tpl.html b/ui/src/app/entity/entity-filter.tpl.html
index f9aac3c..95193f2 100644
--- a/ui/src/app/entity/entity-filter.tpl.html
+++ b/ui/src/app/entity/entity-filter.tpl.html
@@ -112,6 +112,20 @@
aria-label="{{ 'device.name-starts-with' | translate }}">
</md-input-container>
</section>
+ <section layout="column" ng-if="filter.type == types.aliasFilterType.entityViewType.value" id="entityViewTypeFilter">
+ <tb-entity-subtype-autocomplete
+ tb-required="true"
+ the-form="theForm"
+ ng-model="filter.entityViewType"
+ entity-type="types.entityType.entityView">
+ </tb-entity-subtype-autocomplete>
+ <md-input-container class="md-block">
+ <label translate>entity-view.name-starts-with</label>
+ <input name="entityViewNameFilter"
+ ng-model="filter.entityViewNameFilter"
+ aria-label="{{ 'entity-view.name-starts-with' | translate }}">
+ </md-input-container>
+ </section>
<section layout="column" ng-if="filter.type == types.aliasFilterType.relationsQuery.value" id="relationsQueryFilter">
<label class="tb-small">{{ 'alias.root-entity' | translate }}</label>
<section class="tb-root-state-entity-switch" layout="row" layout-align="start center" style="padding-left: 0px;">
@@ -311,4 +325,73 @@
ng-model="filter.deviceTypes">
</tb-entity-subtype-list>
</section>
+ <section layout="column" ng-if="filter.type == types.aliasFilterType.entityViewSearchQuery.value" id="entityViewSearchQueryFilter">
+ <label class="tb-small">{{ 'alias.root-entity' | translate }}</label>
+ <section class="tb-root-state-entity-switch" layout="row" layout-align="start center" style="padding-left: 0px;">
+ <md-switch class="root-state-entity-switch" ng-model="filter.rootStateEntity"
+ aria-label="{{ 'alias.root-state-entity' | translate }}">
+ </md-switch>
+ <label class="tb-small root-state-entity-label" translate>alias.root-state-entity</label>
+ </section>
+ <div flex layout="row" ng-if="!filter.rootStateEntity">
+ <tb-entity-select flex
+ the-form="theForm"
+ tb-required="!filter.rootStateEntity"
+ ng-disabled="filter.rootStateEntity"
+ use-alias-entity-types="true"
+ ng-model="filter.rootEntity">
+ </tb-entity-select>
+ </div>
+ <div flex layout="row" ng-if="filter.rootStateEntity">
+ <md-input-container class="md-block" style="margin-top: 32px;">
+ <label translate>alias.state-entity-parameter-name</label>
+ <input name="stateEntityParamName"
+ placeholder="{{ 'alias.default-entity-parameter-name' | translate }}"
+ ng-model="filter.stateEntityParamName"
+ aria-label="{{ 'alias.state-entity-parameter-name' | translate }}">
+ </md-input-container>
+ <div flex layout="column">
+ <label class="tb-small">{{ 'alias.default-state-entity' | translate }}</label>
+ <tb-entity-select flex
+ the-form="theForm"
+ tb-required="false"
+ use-alias-entity-types="true"
+ ng-model="filter.defaultStateEntity">
+ </tb-entity-select>
+ </div>
+ </div>
+ <div flex layout="row">
+ <md-input-container class="md-block" style="min-width: 100px;">
+ <label translate>relation.direction</label>
+ <md-select required ng-model="filter.direction">
+ <md-option ng-repeat="direction in types.entitySearchDirection" ng-value="direction">
+ {{ ('relation.search-direction.' + direction) | translate}}
+ </md-option>
+ </md-select>
+ </md-input-container>
+ <md-input-container flex class="md-block">
+ <label translate>alias.max-relation-level</label>
+ <input name="maxRelationLevel"
+ type="number"
+ min="1"
+ step="1"
+ placeholder="{{ 'alias.unlimited-level' | translate }}"
+ ng-model="filter.maxLevel"
+ aria-label="{{ 'alias.max-relation-level' | translate }}">
+ </md-input-container>
+ </div>
+ <div class="md-caption" style="color: rgba(0,0,0,0.57);" translate>relation.relation-type</div>
+ <tb-relation-type-autocomplete flex
+ hide-label
+ the-form="theForm"
+ ng-model="filter.relationType"
+ tb-required="false">
+ </tb-relation-type-autocomplete>
+ <div class="md-caption tb-required" style="color: rgba(0,0,0,0.57);" translate>entity-view.entity-view-types</div>
+ <tb-entity-subtype-list
+ tb-required="true"
+ entity-type="types.entityType.entityView"
+ ng-model="filter.entityViewTypes">
+ </tb-entity-subtype-list>
+ </section>
</div>
diff --git a/ui/src/app/entity/entity-filter-view.directive.js b/ui/src/app/entity/entity-filter-view.directive.js
index 8bd3422..5b34107 100644
--- a/ui/src/app/entity/entity-filter-view.directive.js
+++ b/ui/src/app/entity/entity-filter-view.directive.js
@@ -77,6 +77,15 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q,
scope.filterDisplayValue = $translate.instant('alias.filter-type-device-type-description', {deviceType: deviceType});
}
break;
+ case types.aliasFilterType.entityViewType.value:
+ var entityViewType = scope.filter.entityViewType;
+ prefix = scope.filter.entityViewNameFilter;
+ if (prefix && prefix.length) {
+ scope.filterDisplayValue = $translate.instant('alias.filter-type-entity-view-type-and-name-description', {entityViewType: entityViewType, prefix: prefix});
+ } else {
+ scope.filterDisplayValue = $translate.instant('alias.filter-type-entity-view-type-description', {entityViewType: entityViewType});
+ }
+ break;
case types.aliasFilterType.relationsQuery.value:
var rootEntityText;
var directionText;
@@ -134,6 +143,7 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q,
break;
case types.aliasFilterType.assetSearchQuery.value:
case types.aliasFilterType.deviceSearchQuery.value:
+ case types.aliasFilterType.entityViewSearchQuery.value:
allEntitiesText = $translate.instant('alias.all-entities');
anyRelationText = $translate.instant('alias.any-relation');
if (scope.filter.rootStateEntity) {
@@ -165,7 +175,7 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q,
scope.filterDisplayValue = $translate.instant('alias.filter-type-asset-search-query-description',
translationValues
);
- } else {
+ } else if (scope.filter.type == types.aliasFilterType.deviceSearchQuery.value) {
var deviceTypesQuoted = [];
scope.filter.deviceTypes.forEach(function(deviceType) {
deviceTypesQuoted.push("'"+deviceType+"'");
@@ -175,6 +185,16 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q,
scope.filterDisplayValue = $translate.instant('alias.filter-type-device-search-query-description',
translationValues
);
+ } else if (scope.filter.type == types.aliasFilterType.entityViewSearchQuery.value) {
+ var entityViewTypesQuoted = [];
+ scope.filter.entityViewTypes.forEach(function(entityViewType) {
+ entityViewTypesQuoted.push("'"+entityViewType+"'");
+ });
+ var entityViewTypesText = entityViewTypesQuoted.join(', ');
+ translationValues.entityViewTypes = entityViewTypesText;
+ scope.filterDisplayValue = $translate.instant('alias.filter-type-entity-view-search-query-description',
+ translationValues
+ );
}
break;
default:
diff --git a/ui/src/app/entity/entity-subtype-autocomplete.directive.js b/ui/src/app/entity/entity-subtype-autocomplete.directive.js
index 5812715..4c79f9a 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);
@@ -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(
@@ -134,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/entity/entity-subtype-list.directive.js b/ui/src/app/entity/entity-subtype-list.directive.js
index d74a7b0..43bb21f 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) {
@@ -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 () {
@@ -97,6 +103,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 b86e944..8508f15 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);
@@ -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(
@@ -100,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) {
@@ -116,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();
+ });
}
}
}
diff --git a/ui/src/app/entity/relation/relation-table.directive.js b/ui/src/app/entity/relation/relation-table.directive.js
index 872042c..d2586c4 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/entity-view/add-entity-view.tpl.html b/ui/src/app/entity-view/add-entity-view.tpl.html
new file mode 100644
index 0000000..ebf1f1d
--- /dev/null
+++ b/ui/src/app/entity-view/add-entity-view.tpl.html
@@ -0,0 +1,45 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<md-dialog aria-label="{{ 'entity-view.add' | translate }}" style="width: 800px;" tb-help="'entityViews'" help-container-id="help-container">
+ <form name="theForm" ng-submit="vm.add()">
+ <md-toolbar>
+ <div class="md-toolbar-tools">
+ <h2 translate>entity-view.add</h2>
+ <span flex></span>
+ <div id="help-container"></div>
+ <md-button class="md-icon-button" ng-click="vm.cancel()">
+ <ng-md-icon icon="close" aria-label="{{ 'dialog.close' | translate }}"></ng-md-icon>
+ </md-button>
+ </div>
+ </md-toolbar>
+ <md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
+ <span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>
+ <md-dialog-content>
+ <div class="md-dialog-content">
+ <tb-entity-view entity-view="vm.item" is-edit="true" the-form="theForm"></tb-entity-view>
+ </div>
+ </md-dialog-content>
+ <md-dialog-actions layout="row">
+ <span flex></span>
+ <md-button ng-disabled="$root.loading || theForm.$invalid || !theForm.$dirty" type="submit" class="md-raised md-primary">
+ {{ 'action.add' | translate }}
+ </md-button>
+ <md-button ng-disabled="$root.loading" ng-click="vm.cancel()" style="margin-right:20px;">{{ 'action.cancel' | translate }}</md-button>
+ </md-dialog-actions>
+ </form>
+</md-dialog>
\ 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 0000000..8e39546
--- /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 0000000..1149a1d
--- /dev/null
+++ b/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html
@@ -0,0 +1,77 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<md-dialog aria-label="{{ 'entity-view.assign-to-customer' | translate }}">
+ <form name="theForm" ng-submit="vm.assign()">
+ <md-toolbar>
+ <div class="md-toolbar-tools">
+ <h2 translate>entity-view.assign-entity-view-to-customer</h2>
+ <span flex></span>
+ <md-button class="md-icon-button" ng-click="vm.cancel()">
+ <ng-md-icon icon="close" aria-label="{{ 'dialog.close' | translate }}"></ng-md-icon>
+ </md-button>
+ </div>
+ </md-toolbar>
+ <md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
+ <span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>
+ <md-dialog-content>
+ <div class="md-dialog-content">
+ <fieldset>
+ <span translate>entity-view.assign-entity-view-to-customer-text</span>
+ <md-input-container class="md-block" style='margin-bottom: 0px;'>
+ <label> </label>
+ <md-icon aria-label="{{ 'action.search' | translate }}" class="material-icons">
+ search
+ </md-icon>
+ <input id="entity-view-search" autofocus ng-model="vm.searchText"
+ ng-change="vm.searchEntityViewTextUpdated()"
+ placeholder="{{ 'common.enter-search' | translate }}"/>
+ </md-input-container>
+ <div style='min-height: 150px;'>
+ <span translate layout-align="center center"
+ style="text-transform: uppercase; display: flex; height: 150px;"
+ class="md-subhead"
+ ng-show="vm.noData()">entity-view.no-entity-views-text</span>
+ <md-virtual-repeat-container ng-show="vm.hasData()"
+ tb-scope-element="repeatContainer" md-top-index="vm.topIndex" flex
+ style='min-height: 150px; width: 100%;'>
+ <md-list>
+ <md-list-item md-virtual-repeat="entityView in vm.theEntityViews" md-on-demand
+ class="repeated-item" flex>
+ <md-checkbox ng-click="vm.toggleEntityViewSelection($event, entityView)"
+ aria-label="{{ 'item.selected' | translate }}"
+ ng-checked="entityView.selected"></md-checkbox>
+ <span> {{ entityView.name }} </span>
+ </md-list-item>
+ </md-list>
+ </md-virtual-repeat-container>
+ </div>
+ </fieldset>
+ </div>
+ </md-dialog-content>
+ <md-dialog-actions layout="row">
+ <span flex></span>
+ <md-button ng-disabled="$root.loading || vm.entityViews.selectedCount == 0" type="submit"
+ class="md-raised md-primary">
+ {{ 'action.assign' | translate }}
+ </md-button>
+ <md-button ng-disabled="$root.loading" ng-click="vm.cancel()" style="margin-right:20px;">{{ 'action.cancel' |
+ translate }}
+ </md-button>
+ </md-dialog-actions>
+ </form>
+</md-dialog>
\ 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 0000000..3e09ae6
--- /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 0000000..7c1fa25
--- /dev/null
+++ b/ui/src/app/entity-view/assign-to-customer.tpl.html
@@ -0,0 +1,76 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<md-dialog aria-label="{{ 'entity-view.assign-entity-view-to-customer' | translate }}">
+ <form name="theForm" ng-submit="vm.assign()">
+ <md-toolbar>
+ <div class="md-toolbar-tools">
+ <h2 translate>entity-view.assign-entity-view-to-customer</h2>
+ <span flex></span>
+ <md-button class="md-icon-button" ng-click="vm.cancel()">
+ <ng-md-icon icon="close" aria-label="{{ 'dialog.close' | translate }}"></ng-md-icon>
+ </md-button>
+ </div>
+ </md-toolbar>
+ <md-progress-linear class="md-warn" md-mode="indeterminate" ng-disabled="!$root.loading" ng-show="$root.loading"></md-progress-linear>
+ <span style="min-height: 5px;" flex="" ng-show="!$root.loading"></span>
+ <md-dialog-content>
+ <div class="md-dialog-content">
+ <fieldset>
+ <span translate>entity-view.assign-to-customer-text</span>
+ <md-input-container class="md-block" style='margin-bottom: 0px;'>
+ <label> </label>
+ <md-icon aria-label="{{ 'action.search' | translate }}" class="material-icons">
+ search
+ </md-icon>
+ <input id="customer-search" autofocus ng-model="vm.searchText"
+ ng-change="vm.searchCustomerTextUpdated()"
+ placeholder="{{ 'common.enter-search' | translate }}"/>
+ </md-input-container>
+ <div style='min-height: 150px;'>
+ <span translate layout-align="center center"
+ style="text-transform: uppercase; display: flex; height: 150px;"
+ class="md-subhead"
+ ng-show="vm.noData()">customer.no-customers-text</span>
+ <md-virtual-repeat-container ng-show="vm.hasData()"
+ tb-scope-element="repeatContainer" md-top-index="vm.topIndex" flex
+ style='min-height: 150px; width: 100%;'>
+ <md-list>
+ <md-list-item md-virtual-repeat="customer in vm.theCustomers" md-on-demand
+ class="repeated-item" flex>
+ <md-checkbox ng-click="vm.toggleCustomerSelection($event, customer)"
+ aria-label="{{ 'item.selected' | translate }}"
+ ng-checked="vm.isCustomerSelected(customer)"></md-checkbox>
+ <span> {{ customer.title }} </span>
+ </md-list-item>
+ </md-list>
+ </md-virtual-repeat-container>
+ </div>
+ </fieldset>
+ </div>
+ </md-dialog-content>
+ <md-dialog-actions layout="row">
+ <span flex></span>
+ <md-button ng-disabled="$root.loading || vm.customers.selection==null" type="submit" class="md-raised md-primary">
+ {{ 'action.assign' | translate }}
+ </md-button>
+ <md-button ng-disabled="$root.loading" ng-click="vm.cancel()" style="margin-right:20px;">{{ 'action.cancel' |
+ translate }}
+ </md-button>
+ </md-dialog-actions>
+ </form>
+</md-dialog>
\ No newline at end of file
ui/src/app/entity-view/entity-view.controller.js 483(+483 -0)
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 0000000..2c8c8ea
--- /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) {
+ assignEntityViewsToCustomer($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();
+ });
+ });
+ }
+}
ui/src/app/entity-view/entity-view.directive.js 151(+151 -0)
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 0000000..761930e
--- /dev/null
+++ b/ui/src/app/entity-view/entity-view.directive.js
@@ -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.
+ */
+
+import './entity-view.scss';
+
+/* 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($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;
+ scope.assignedCustomer = null;
+
+ scope.allowedEntityTypes = [types.entityType.device, types.entityType.asset];
+
+ var semicolon = 186;
+ scope.separatorKeys = [$mdConstant.KEY_CODE.ENTER, $mdConstant.KEY_CODE.COMMA, semicolon];
+
+ 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;
+ scope.isPublic = customer.isPublic;
+ }
+ );
+ } else {
+ scope.isAssignedToCustomer = false;
+ scope.isPublic = false;
+ scope.assignedCustomer = null;
+ }
+ 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 = {};
+ scope.entityView.keys.timeseries = [];
+ scope.entityView.keys.attributes = {};
+ scope.entityView.keys.attributes.ss = [];
+ scope.entityView.keys.attributes.cs = [];
+ scope.entityView.keys.attributes.sh = [];
+ }
+ }
+ });
+
+ 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) {
+ if (newDate.getTime() > scope.maxStartTimeMs) {
+ scope.startTimeMs = angular.copy(scope.maxStartTimeMs);
+ }
+ }
+ updateMinMaxDates();
+ });
+
+ scope.$watch('endTimeMs', function (newDate) {
+ if (newDate) {
+ if (newDate.getTime() < scope.minEndTimeMs) {
+ scope.endTimeMs = angular.copy(scope.minEndTimeMs);
+ }
+ }
+ updateMinMaxDates();
+ });
+
+ function updateMinMaxDates() {
+ 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;
+ }
+ }
+ }
+
+ 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: '&',
+ onMakePublic: '&',
+ onUnassignFromCustomer: '&',
+ onDeleteEntityView: '&'
+ }
+ };
+}
ui/src/app/entity-view/entity-view.routes.js 72(+72 -0)
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 0000000..99e89dd
--- /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: {
+ entityViewsType: 'tenant',
+ searchEnabled: true,
+ searchByEntitySubtype: true,
+ searchEntityType: types.entityType.entityView,
+ pageTitle: 'entity-view.entity-views'
+ },
+ ncyBreadcrumb: {
+ label: '{"icon": "view_quilt", "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: {
+ entityViewsType: 'customer',
+ searchEnabled: true,
+ searchByEntitySubtype: true,
+ searchEntityType: types.entityType.entityView,
+ pageTitle: 'customer.entity-views'
+ },
+ ncyBreadcrumb: {
+ label: '{"icon": "view_quilt", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}'
+ }
+ });
+
+}
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 0000000..1e90928
--- /dev/null
+++ b/ui/src/app/entity-view/entity-view-card.tpl.html
@@ -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.
+
+-->
+<div flex layout="column" style="margin-top: -10px;">
+ <div style="text-transform: uppercase; padding-bottom: 5px;">{{vm.item.type}}</div>
+ <div class="tb-card-description">{{vm.item.additionalInfo.description}}</div>
+ <div style="padding-top: 5px;" class="tb-small" ng-show="vm.isAssignedToCustomer()">{{'entity-view.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'</div>
+</div>
ui/src/app/entity-view/entity-view-fieldset.tpl.html 212(+212 -0)
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 0000000..766f8c8
--- /dev/null
+++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html
@@ -0,0 +1,212 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+-->
+<md-button ng-click="onMakePublic({event: $event})"
+ ng-show="!isEdit && entityViewScope === 'tenant' && !isAssignedToCustomer && !isPublic"
+ class="md-raised md-primary">{{ 'entity-view.make-public' | translate }}</md-button>
+<md-button ng-click="onAssignToCustomer({event: $event})"
+ ng-show="!isEdit && entityViewScope === 'tenant' && !isAssignedToCustomer"
+ class="md-raised md-primary">{{ 'entity-view.assign-to-customer' | translate }}</md-button>
+<md-button ng-click="onUnassignFromCustomer({event: $event})"
+ ng-show="!isEdit && (entityViewScope === 'customer' || entityViewScope === 'tenant') && isAssignedToCustomer"
+ class="md-raised md-primary">{{'entity-view.unassign-from-customer' | translate }}</md-button>
+<md-button ng-click="onDeleteEntityView({event: $event})"
+ ng-show="!isEdit && entityViewScope === 'tenant'"
+ class="md-raised md-primary">{{ 'entity-view.delete' | translate }}</md-button>
+
+<div layout="row">
+ <md-button ngclipboard data-clipboard-action="copy"
+ ngclipboard-success="onEntityViewIdCopied(e)"
+ data-clipboard-text="{{entityView.id.id}}" ng-show="!isEdit"
+ class="md-raised">
+ <md-icon md-svg-icon="mdi:clipboard-arrow-left"></md-icon>
+ <span translate>entity-view.copyId</span>
+ </md-button>
+</div>
+
+<md-content class="md-padding" layout="column">
+ <md-input-container class="md-block"
+ ng-show="!isEdit && isAssignedToCustomer && entityViewScope === 'tenant'">
+ <label translate>entity-view.assignedToCustomer</label>
+ <input ng-model="assignedCustomer.title" disabled>
+ </md-input-container>
+ <fieldset ng-disabled="$root.loading || !isEdit">
+ <md-input-container class="md-block">
+ <label translate>entity-view.name</label>
+ <input required name="name" ng-model="entityView.name">
+ <div ng-messages="theForm.name.$error">
+ <div translate ng-message="required">entity-view.name-required</div>
+ </div>
+ </md-input-container>
+ <tb-entity-subtype-autocomplete
+ ng-disabled="$root.loading || !isEdit"
+ tb-required="true"
+ the-form="theForm"
+ ng-model="entityView.type"
+ entity-type="types.entityType.entityView">
+ </tb-entity-subtype-autocomplete>
+ <section layout="column">
+ <label translate class="tb-title no-padding">entity-view.target-entity</label>
+ <tb-entity-select flex ng-disabled="!isEdit"
+ the-form="theForm"
+ tb-required="true"
+ allowed-entity-types="allowedEntityTypes"
+ ng-model="entityView.entityId">
+ </tb-entity-select>
+ </section>
+ <md-expansion-panel-group class="tb-entity-view-panel-group" ng-class="{'disabled': $root.loading || !isEdit}"
+ auto-expand="true"
+ multiple="true"
+ md-component-id="attributesPanelGroup">
+ <md-expansion-panel md-component-id="{{attributesPanelId}}">
+ <md-expansion-panel-collapsed>
+ <div class="tb-panel-title">{{ 'entity-view.attributes-propagation' | translate }}</div>
+ <span flex></span>
+ <md-expansion-panel-icon></md-expansion-panel-icon>
+ </md-expansion-panel-collapsed>
+ <md-expansion-panel-expanded>
+ <md-expansion-panel-header ng-click="$mdExpansionPanel(attributesPanelId).collapse()">
+ <div class="tb-panel-title">{{ 'entity-view.attributes-propagation' | translate }}</div>
+ <span flex></span>
+ <md-expansion-panel-icon></md-expansion-panel-icon>
+ </md-expansion-panel-header>
+ <md-expansion-panel-content>
+ <div translate class="tb-hint">entity-view.attributes-propagation-hint</div>
+ <label translate class="tb-title no-padding">entity-view.client-attributes</label>
+ <md-chips style="padding-bottom: 15px;"
+ ng-required="false"
+ readonly="!isEdit"
+ ng-model="entityView.keys.attributes.cs"
+ placeholder="{{'entity-view.client-attributes-placeholder' | translate}}"
+ md-separator-keys="separatorKeys">
+ <md-autocomplete
+ md-no-cache="true"
+ id="ca_datakey"
+ md-selected-item="selectedAttributeDataKey"
+ md-search-text="attributeDataKeySearchText"
+ md-items="item in dataKeysSearch(attributeDataKeySearchText, types.dataKeyType.attribute)"
+ md-item-text="item.name"
+ md-min-length="0"
+ placeholder="{{'entity-view.client-attributes-placeholder' | translate }}"
+ md-menu-class="tb-attribute-datakey-autocomplete">
+ <span md-highlight-text="attributeDataKeySearchText" md-highlight-flags="^i">{{item}}</span>
+ </md-autocomplete>
+ </md-chips>
+ <label translate class="tb-title no-padding">entity-view.shared-attributes</label>
+ <md-chips style="padding-bottom: 15px;"
+ ng-required="false"
+ readonly="!isEdit"
+ ng-model="entityView.keys.attributes.sh"
+ placeholder="{{'entity-view.shared-attributes-placeholder' | translate}}"
+ md-separator-keys="separatorKeys">
+ <md-autocomplete
+ md-no-cache="true"
+ id="sh_datakey"
+ md-selected-item="selectedAttributeDataKey"
+ md-search-text="attributeDataKeySearchText"
+ md-items="item in dataKeysSearch(attributeDataKeySearchText, types.dataKeyType.attribute)"
+ md-item-text="item.name"
+ md-min-length="0"
+ placeholder="{{'entity-view.shared-attributes-placeholder' | translate }}"
+ md-menu-class="tb-attribute-datakey-autocomplete">
+ <span md-highlight-text="attributeDataKeySearchText" md-highlight-flags="^i">{{item}}</span>
+ </md-autocomplete>
+ </md-chips>
+ <label translate class="tb-title no-padding">entity-view.server-attributes</label>
+ <md-chips style="padding-bottom: 15px;"
+ ng-required="false"
+ readonly="!isEdit"
+ ng-model="entityView.keys.attributes.ss"
+ placeholder="{{'entity-view.server-attributes-placeholder' | translate}}"
+ md-separator-keys="separatorKeys">
+ <md-autocomplete
+ md-no-cache="true"
+ id="ss_datakey"
+ md-selected-item="selectedAttributeDataKey"
+ md-search-text="attributeDataKeySearchText"
+ 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 }}"
+ md-menu-class="tb-attribute-datakey-autocomplete">
+ <span md-highlight-text="attributeDataKeySearchText" md-highlight-flags="^i">{{item}}</span>
+ </md-autocomplete>
+ </md-chips>
+ </md-expansion-panel-content>
+ </md-expansion-panel-expanded>
+ </md-expansion-panel>
+ <md-expansion-panel md-component-id="{{timeseriesPanelId}}">
+ <md-expansion-panel-collapsed>
+ <div class="tb-panel-title">{{ 'entity-view.timeseries-data' | translate }}</div>
+ <span flex></span>
+ <md-expansion-panel-icon></md-expansion-panel-icon>
+ </md-expansion-panel-collapsed>
+ <md-expansion-panel-expanded>
+ <md-expansion-panel-header ng-click="$mdExpansionPanel(timeseriesPanelId).collapse()">
+ <div class="tb-panel-title">{{ 'entity-view.timeseries-data' | translate }}</div>
+ <span flex></span>
+ <md-expansion-panel-icon></md-expansion-panel-icon>
+ </md-expansion-panel-header>
+ <md-expansion-panel-content>
+ <div translate class="tb-hint">entity-view.timeseries-data-hint</div>
+ <label translate class="tb-title no-padding">entity-view.timeseries</label>
+ <md-chips ng-required="false"
+ readonly="!isEdit"
+ ng-model="entityView.keys.timeseries"
+ placeholder="{{'entity-view.timeseries-placeholder' | translate}}"
+ md-separator-keys="separatorKeys">
+ <md-autocomplete
+ md-no-cache="true"
+ id="timeseries_datakey"
+ md-selected-item="selectedTimeseriesDataKey"
+ md-search-text="timeseriesDataKeySearchText"
+ md-items="item in dataKeysSearch(timeseriesDataKeySearchText, types.dataKeyType.timeseries)"
+ md-item-text="item.name"
+ md-min-length="0"
+ placeholder="{{'entity-view.timeseries-placeholder' | translate }}"
+ md-menu-class="tb-timeseries-datakey-autocomplete">
+ <span md-highlight-text="timeseriesDataKeySearchText" md-highlight-flags="^i">{{item}}</span>
+ </md-autocomplete>
+ </md-chips>
+ </md-expansion-panel-content>
+ </md-expansion-panel-expanded>
+ </md-expansion-panel>
+ </md-expansion-panel-group>
+ <section layout="row" layout-align="start start">
+ <mdp-date-picker ng-model="startTimeMs"
+ mdp-max-date="maxStartTimeMs"
+ mdp-placeholder="{{ 'entity-view.start-date' | translate }}"></mdp-date-picker>
+ <mdp-time-picker ng-model="startTimeMs"
+ mdp-max-date="maxStartTimeMs"
+ mdp-placeholder="{{ 'entity-view.start-ts' | translate }}"
+ mdp-auto-switch="true"></mdp-time-picker>
+ </section>
+ <section layout="row" layout-align="start start">
+ <mdp-date-picker ng-model="endTimeMs"
+ mdp-min-date="minEndTimeMs"
+ mdp-placeholder="{{ 'entity-view.end-date' | translate }}"></mdp-date-picker>
+ <mdp-time-picker ng-model="endTimeMs"
+ mdp-min-date="minEndTimeMs"
+ mdp-placeholder="{{ 'entity-view.end-ts' | translate }}"
+ mdp-auto-switch="true"></mdp-time-picker>
+ </section>
+ <md-input-container class="md-block">
+ <label translate>entity-view.description</label>
+ <textarea ng-model="entityView.additionalInfo.description" rows="2"></textarea>
+ </md-input-container>
+ </fieldset>
+</md-content>
ui/src/app/entity-view/entity-views.tpl.html 75(+75 -0)
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 0000000..50a0adb
--- /dev/null
+++ b/ui/src/app/entity-view/entity-views.tpl.html
@@ -0,0 +1,75 @@
+<!--
+
+ Copyright © 2016-2018 The Thingsboard Authors
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT 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-grid grid-configuration="vm.entityViewGridConfig">
+ <details-buttons tb-help="'entityViews'" help-container-id="help-container">
+ <div id="help-container"></div>
+ </details-buttons>
+ <md-tabs ng-class="{'tb-headless': vm.grid.detailsConfig.isDetailsEditMode}"
+ id="tabs" md-border-bottom flex class="tb-absolute-fill">
+ <md-tab label="{{ 'entity-view.details' | translate }}">
+ <tb-entity-view entity-view="vm.grid.operatingItem()"
+ is-edit="vm.grid.detailsConfig.isDetailsEditMode"
+ entity-view-scope="vm.entityViewsScope"
+ the-form="vm.grid.detailsForm"
+ 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-delete-entity-view="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)"></tb-entity-view>
+ </md-tab>
+ <md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode" md-on-select="vm.grid.triggerResize()" label="{{ 'attribute.attributes' | translate }}">
+ <tb-attribute-table flex
+ entity-id="vm.grid.operatingItem().id.id"
+ entity-type="{{vm.types.entityType.entityView}}"
+ entity-name="vm.grid.operatingItem().name"
+ default-attribute-scope="{{vm.types.attributesScope.client.value}}">
+ </tb-attribute-table>
+ </md-tab>
+ <md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode" md-on-select="vm.grid.triggerResize()" label="{{ 'attribute.latest-telemetry' | translate }}">
+ <tb-attribute-table flex
+ entity-id="vm.grid.operatingItem().id.id"
+ entity-type="{{vm.types.entityType.entityView}}"
+ entity-name="vm.grid.operatingItem().name"
+ default-attribute-scope="{{vm.types.latestTelemetry.value}}"
+ disable-attribute-scope-selection="true">
+ </tb-attribute-table>
+ </md-tab>
+ <md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode" md-on-select="vm.grid.triggerResize()" label="{{ 'alarm.alarms' | translate }}">
+ <tb-alarm-table flex entity-type="vm.types.entityType.entityView"
+ entity-id="vm.grid.operatingItem().id.id">
+ </tb-alarm-table>
+ </md-tab>
+ <md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode" md-on-select="vm.grid.triggerResize()" label="{{ 'entity-view.events' | translate }}">
+ <tb-event-table flex entity-type="vm.types.entityType.entityView"
+ entity-id="vm.grid.operatingItem().id.id"
+ tenant-id="vm.grid.operatingItem().tenantId.id"
+ default-event-type="{{vm.types.eventType.error.value}}">
+ </tb-event-table>
+ </md-tab>
+ <md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode" md-on-select="vm.grid.triggerResize()" label="{{ 'relation.relations' | translate }}">
+ <tb-relation-table flex
+ entity-id="vm.grid.operatingItem().id.id"
+ entity-type="{{vm.types.entityType.entityView}}">
+ </tb-relation-table>
+ </md-tab>
+ <md-tab ng-if="!vm.grid.detailsConfig.isDetailsEditMode && vm.grid.isTenantAdmin()" md-on-select="vm.grid.triggerResize()" label="{{ 'audit-log.audit-logs' | translate }}">
+ <tb-audit-log-table flex entity-type="vm.types.entityType.entityView"
+ entity-id="vm.grid.operatingItem().id.id"
+ audit-log-mode="{{vm.types.auditLogMode.entity}}">
+ </tb-audit-log-table>
+ </md-tab>
+</tb-grid>
ui/src/app/entity-view/index.js 41(+41 -0)
diff --git a/ui/src/app/entity-view/index.js b/ui/src/app/entity-view/index.js
new file mode 100644
index 0000000..69f669e
--- /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 {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';
+
+export default angular.module('thingsboard.entityView', [
+ uiRouter,
+ thingsboardGrid,
+ thingsboardApiUser,
+ thingsboardApiEntityView,
+ thingsboardApiCustomer
+])
+ .config(EntityViewRoutes)
+ .controller('EntityViewController', EntityViewController)
+ .controller('EntityViewCardController', EntityViewCardController)
+ .controller('AssignEntityViewToCustomerController', AssignEntityViewToCustomerController)
+ .controller('AddEntityViewsToCustomerController', AddEntityViewsToCustomerController)
+ .directive('tbEntityView', EntityViewDirective)
+ .name;
diff --git a/ui/src/app/event/event-row.directive.js b/ui/src/app/event/event-row.directive.js
index b808fb8..5f84187 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 18d281c..73c5d93 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/help/help-links.constant.js b/ui/src/app/help/help-links.constant.js
index 458c118..9e6bb52 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/import-export/import-export.service.js b/ui/src/app/import-export/import-export.service.js
index d64441f..e08fd24 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) {
ui/src/app/layout/home.scss 2(+1 -1)
diff --git a/ui/src/app/layout/home.scss b/ui/src/app/layout/home.scss
index 48c1553..43b8329 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,
ui/src/app/layout/index.js 4(+2 -2)
diff --git a/ui/src/app/layout/index.js b/ui/src/app/layout/index.js
index 8f2958d..7c7e870 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';
@@ -48,6 +47,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';
@@ -62,7 +62,6 @@ import BreadcrumbIcon from './breadcrumb-icon.filter';
export default angular.module('thingsboard.home', [
uiRouter,
ngSanitize,
- FBAngular.name,
'ncy-angular-breadcrumb',
thingsboardMenu,
thingsboardHomeLinks,
@@ -79,6 +78,7 @@ export default angular.module('thingsboard.home', [
thingsboardProfile,
thingsboardAsset,
thingsboardDevice,
+ thingsboardEntityView,
thingsboardWidgetLibrary,
thingsboardDashboard,
thingsboardRuleChain,
ui/src/app/locale/locale.constant-en_US.json 122(+118 -4)
diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json
index 6321d63..30e1eb1 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",
@@ -153,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",
@@ -338,10 +348,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",
@@ -543,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",
@@ -706,6 +723,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} }",
@@ -748,7 +769,99 @@
"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"
+ },
+ "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",
+ "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}}'.<br>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):<br/>{{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",
+ "shared-attributes": "Shared attributes",
+ "server-attributes": "Server attributes",
+ "timeseries": "Timeseries",
+ "client-attributes-placeholder": "Client attributes",
+ "shared-attributes-placeholder": "Shared attributes",
+ "server-attributes-placeholder": "Server attributes",
+ "timeseries-placeholder": "Timeseries",
+ "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",
@@ -1460,7 +1573,8 @@
"ko_KR": "Korean",
"ru_RU": "Russian",
"es_ES": "Spanish",
- "ja_JA": "Japanese"
+ "ja_JA": "Japanese",
+ "tr_TR": "Turkish"
}
}
}
ui/src/app/locale/locale.constant-es_ES.json 2107(+1173 -934)
diff --git a/ui/src/app/locale/locale.constant-es_ES.json b/ui/src/app/locale/locale.constant-es_ES.json
index 733db14..7b02141 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!<br/>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!<br/>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.<br/>¿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..<br/>¿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 <b>{{dashboardTitle}}</b> es ahora público y podrá ser accedido desde: <a href='{{publicLink}}' target='_blank'>aquí</a>:",
- "public-dashboard-notice": "<b>Nota:</b> 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 <b>{{dashboardTitle}}</b> es ahora público y es accesible a través del siguiente enlace público <a href='{{publicLink}}' target='_blank'></a>:",
+ "public-dashboard-notice": "<b>Nota</b> 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.<br/>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.<br/>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}}'.<br> 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}}'.<br>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):<br/>{{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):<br/>{{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):<br/>{{widgetsList}}",
- "duplicate-alias-error": "Duplicate alias found '{{alias}}'.<br>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):<br/>{{widgetsList}}",
+ "duplicate-alias-error": "Alias duplicado fue encontrado '{{alias}}'.<br>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}}'.<br>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):<br/>{{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",
+ "timeseries": "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,210 +1309,250 @@
"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 <a href='{{activationLink}}' target='_blank'>activation link</a> :",
+ "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.<br>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.<br>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.<br/>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"
+ "ja_JA": "Japonés",
+ "tr_TR": "Turco"
}
}
}
ui/src/app/locale/locale.constant-fr_FR.json 2919(+1460 -1459)
diff --git a/ui/src/app/locale/locale.constant-fr_FR.json b/ui/src/app/locale/locale.constant-fr_FR.json
index 2c0a290..dd2fa0d 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! <br/> 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. <br/> Ê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. <br/> 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": "<b> Remarque: </ b> N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.",
- "public-dashboard-text": "Votre tableau de bord <b> {{dashboardTitle}} </ b> est maintenant public et accessible via le lien public <a href='{{publicLink}}' target='_blank'> </a>: ",
- "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}}'. <br> 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: <br/> {{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}}'. <br> 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: <br/> {{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: <a href='{{activationLink}}' target='_blank'></a>",
- "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. <br> 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à. <br/> 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! <br/> 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. <br/> Ê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. <br/> 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": "<b> Remarque: </ b> N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.",
+ "public-dashboard-text": "Votre tableau de bord <b> {{dashboardTitle}} </ b> est maintenant public et accessible via le lien public <a href='{{publicLink}}' target='_blank'> </a>: ",
+ "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}}'. <br> 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: <br/> {{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}}'. <br> 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: <br/> {{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_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: <a href='{{activationLink}}' target='_blank'></a>",
+ "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. <br> 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à. <br/> 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"
+ }
}
ui/src/app/locale/locale.constant-it_IT.json 89(+45 -44)
diff --git a/ui/src/app/locale/locale.constant-it_IT.json b/ui/src/app/locale/locale.constant-it_IT.json
index fcdd745..49f9b48 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",
@@ -1441,7 +1441,8 @@
"it_IT": "Italiano",
"ru_RU": "Russo",
"es_ES": "Spagnolo",
- "ja_JA": "Giapponese"
+ "ja_JA": "Giapponese",
+ "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 77765ef..2ab2bcb 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_TR": "トルコ語"
}
}
}
diff --git a/ui/src/app/locale/locale.constant-ko_KR.json b/ui/src/app/locale/locale.constant-ko_KR.json
index 6027ef8..62c0054 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_TR": "터키어"
}
}
}
diff --git a/ui/src/app/locale/locale.constant-ru_RU.json b/ui/src/app/locale/locale.constant-ru_RU.json
index 9d2c713..eb9996d 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_TR": "Турецкий"
}
}
ui/src/app/locale/locale.constant-tr_TR.json 1545(+1545 -0)
diff --git a/ui/src/app/locale/locale.constant-tr_TR.json b/ui/src/app/locale/locale.constant-tr_TR.json
new file mode 100644
index 0000000..54176d8
--- /dev/null
+++ b/ui/src/app/locale/locale.constant-tr_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 konuma erişim haklarınız yok! <br/> 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": "Oturum yenilenemiyor"
+ },
+ "action": {
+ "activate": "Etkinleştir",
+ "suspend": "Askıya al",
+ "save": "Kaydet",
+ "saveAs": "Farklı Kaydet",
+ "cancel": "İptal",
+ "ok": "Tamam",
+ "delete": "Sil",
+ "add": "Ekle",
+ "yes": "Evet",
+ "no": "Hayır",
+ "update": "Güncelle",
+ "remove": "Kaldır",
+ "search": "Ara",
+ "clear-search": "Aramayı Temizle",
+ "assign": "Ata",
+ "unassign": "Atamayı kaldır",
+ "share": "Paylaş",
+ "make-private": "Özel yap",
+ "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": "Çalıştır",
+ "sign-in": "Giriş yap!",
+ "edit": "Düzenle",
+ "view": "Görüntüle",
+ "create": "Oluştur",
+ "drag": "Sürükle",
+ "refresh": "Yenile",
+ "undo": "Geri al",
+ "copy": "Kopyala",
+ "paste": "Yapıştır",
+ "copy-reference": "Referansı kopyala",
+ "paste-reference": "Referansı yapıştır",
+ "import": "İçe aktar",
+ "export": "Dışa aktar",
+ "share-via": "{{provider}} ile paylaş"
+ },
+ "aggregation": {
+ "aggregation": "Toplama",
+ "function": "Veri toplama işlevi",
+ "limit": "Maksimum değerler",
+ "group-interval": "Gruplama aralığı",
+ "min": "Min",
+ "max": "Maks",
+ "avg": "Ortalama",
+ "sum": "Toplam",
+ "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 e-postası başarıyla gönderildi!",
+ "base-url": "Temel URL",
+ "base-url-required": "Temel URL gerekli.",
+ "mail-from": "Gönderen Kişi",
+ "mail-from-required": "Gönderen Kişi gerekli.",
+ "smtp-protocol": "SMTP protokolü",
+ "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 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'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 alarm bulunamadı.",
+ "alarm-required": "Alarm gerekli",
+ "alarm-status": "Alarm durumu",
+ "search-status": {
+ "ANY": "Herhangi biri",
+ "ACTIVE": "Aktif",
+ "CLEARED": "Temizlendi",
+ "ACK": "Onaylandı",
+ "UNACK": "Onaylanmadı"
+ },
+ "display-status": {
+ "ACTIVE_UNACK": "Aktif Onaylanmadı",
+ "ACTIVE_ACK": "Aktif Onaylandı",
+ "CLEARED_UNACK": "Temizlendi Onaylanmadı",
+ "CLEARED_ACK": "Temizlendi Onaylandı"
+ },
+ "no-alarms-prompt": "Alarm bulunamadı",
+ "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şlangıç zamanı",
+ "end-time": "Bitiş zamanı",
+ "ack-time": "Onaylanma zamanı",
+ "clear-time": "Temizlenme zamanı",
+ "severity-critical": "Kritik",
+ "severity-major": "Birincil",
+ "severity-minor": "İkincil",
+ "severity-warning": "Uyarı",
+ "severity-indeterminate": "Belirsiz",
+ "acknowledge": "Onayla",
+ "clear": "Temizle",
+ "search": "Alarm ara",
+ "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, 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",
+ "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": "'{{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}} 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 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": "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": "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": "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 varlık bulunamadı.",
+ "asset-type-list-empty": "Herhangi bir varlık türü bulunamadı.",
+ "asset-types": "Varlık türleri",
+ "name": "İsim",
+ "name-required": "İsim gerekli.",
+ "description": "Açıklama",
+ "type": "Tür",
+ "type-required": "Tür gerekli.",
+ "details": "Detaylar",
+ "events": "Olaylar",
+ "add-asset-text": "Yeni varlık ekle",
+ "asset-details": "Varlık detayları",
+ "assign-assets": "Varlıkları 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, 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, 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.",
+ "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, 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ı",
+ "select-asset": "Varlık seç",
+ "no-assets-matching": "'{{entity}}' isimli varlık bulunamadı.",
+ "asset-required": "Varlık gerekli",
+ "name-starts-with": "... ile başlayan varlık adı"
+ },
+ "attribute": {
+ "attributes": "Öznitelikler",
+ "latest-telemetry": "Son telemetri",
+ "attributes-scope": "Varlık öznitelik kapsamı",
+ "scope-latest-telemetry": "Son telemetri",
+ "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": "Öznitelik anahtarı gerekli.",
+ "value": "Değer",
+ "value-required": "Öznitelik değeri gerekli.",
+ "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",
+ "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, 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",
+ "audit-logs": "Loglar ve Hatalar",
+ "timestamp": "Zaman",
+ "entity-type": "Kaynak",
+ "entity-name": "İsim",
+ "user": "Kullanıcı",
+ "type": "Tür",
+ "status": "Durum",
+ "details": "Detaylar",
+ "type-added": "Eklendi",
+ "type-deleted": "Silindi",
+ "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": "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": "Ö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ı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şiklikler var. Sayfadan ayrılmak istediğinize emin misiniz?",
+ "html-message": "Kaydedilmemiş değişiklikler var.<br/>Sayfadan ayrılmak istediğinize emin misiniz?",
+ "title": "Kaydedilmemiş Değişiklikler"
+ },
+ "contact": {
+ "country": "Ülke",
+ "city": "Şehir",
+ "state": "Eyalet / İl",
+ "postal-code": "Posta Kodu",
+ "postal-code-invalid": "Geçersiz Posta Kodu.",
+ "address": "Addres",
+ "address2": "Addres 2",
+ "phone": "Telefon",
+ "email": "E-posta",
+ "no-address": "Adres yok"
+ },
+ "common": {
+ "username": "Kullanıcı adı",
+ "password": "Parola",
+ "enter-username": "Kullanıcı adı gir",
+ "enter-password": "Parola gir",
+ "enter-search": "Arama gir"
+ },
+ "content-type": {
+ "json": "Json",
+ "text": "Metin",
+ "binary": "İkili (Base64)"
+ },
+ "customer": {
+ "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": "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": "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, 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",
+ "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": "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": "Tarihinden",
+ "time-from": "Saatinden",
+ "date-to": "Tarihine",
+ "time-to": "Saatine"
+ },
+ "dashboard": {
+ "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": "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": "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": "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, 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, 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, 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, plural, 1 {1 kontrol panelindeki} other {# kontrol panelindeki} } atamayı kaldırmak istediğinize emin misiniz?",
+ "unassign-dashboards-text": "Onaylandıktan <sonra seçili kontrol panellerinin atamaları kaldırılacak ve kullanıcı grubu tarafından erişilemez hale gelecektir.",
+ "public-dashboard-title": "Kontrol paneli açık hale getirildi",
+ "public-dashboard-text": "Kontrol paneliniz <b>{{dashboardTitle}}</b> açık hale getirildi ve bu <a href='{{publicLink}}' target='_blank'>bağlantıdan</a> erişilebilir durumda",
+ "public-dashboard-notice": "<b>Not:</b> 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": "Kontrol panelini aç",
+ "set-background": "Arka plan belirle",
+ "background-color": "Arka plan rengi",
+ "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": "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": "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": "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": "Kontro paneli kısa adları yapılandırma hatası",
+ "invalid-aliases-config": "Kısa ad filtresiyle eşleşen aygıt bulunamadı.<br/>",
+ "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, 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",
+ "state": "Kontrol paneli durumu",
+ "state-name": "İsim",
+ "state-name-required": "Kontrol paneli durumu ismi gerekli.",
+ "state-id": "Durum Kimliği",
+ "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 denetleyicisi"
+ },
+ "datakey": {
+ "settings": "Ayarlar",
+ "advanced": "İleri düzey",
+ "label": "Etiket",
+ "color": "Renk",
+ "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": "Öznitelikler",
+ "alarm": "Alarm alanları",
+ "timeseries-required": "Zaman serisi öğesi gerekli.",
+ "timeseries-or-attributes-required": "Zaman serisi/öznitelikler öğesi gerekli.",
+ "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, plural, 1 {1 fonksiyon türü kabul edilir.} other {# fonksiyon türü kabul edilir} }"
+ },
+ "datasource": {
+ "type": "Veri kaynağı türü",
+ "name": "İsim",
+ "add-datasource-prompt": "Lütfen veri kaynağı ekleyin"
+ },
+ "details": {
+ "edit-mode": "Düzenleme modu",
+ "toggle-edit-mode": "Düzenleme modunu aç/kapat"
+ },
+ "device": {
+ "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": "'{{alias}}' daha önce kaydedilmiş.<br>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": "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": "Aygıt sil",
+ "assign-devices": "Aygıt 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, 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.",
+ "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": "'{{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, 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, 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ü",
+ "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": "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:<br/>{{widgetsList}}",
+ "is-gateway": "Ağ geçidi mi?",
+ "public": "Açık",
+ "device-public": "Aygıt açık",
+ "select-device": "Aygıt seç"
+ },
+ "dialog": {
+ "close": "Kapat"
+ },
+ "error": {
+ "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": "Öğ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:<br/>{{widgetsList}}",
+ "duplicate-alias-error": "'{{alias}}' daha önce kaydedilmiş.<br>Öğ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 öğ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": "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": "Hiçbir anahtar bulunamadı.",
+ "no-key-matching": "'{{key}}' bulunamadı.",
+ "create-new-key": "Yeni bir tane oluştur!",
+ "type": "Tür",
+ "type-required": "Öğe türü gerekli.",
+ "type-device": "Aygıt",
+ "type-devices": "Aygıtlar",
+ "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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, plural, 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ü",
+ "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, 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 { 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, 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": "{ 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.",
+ "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: <br/> {{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": "Olay türü",
+ "type-error": "Hata",
+ "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": "Hiçbir olay bulunamadı",
+ "error": "Hata",
+ "alarm": "Alarm",
+ "event-time": "Olay zamanı",
+ "server": "Sunucu",
+ "body": "İçerik //(Body)",
+ "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": "Olay",
+ "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, plural, 1 {1 uzantı} other {# 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, 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",
+ "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 ekran yap",
+ "exit": "Tam ekrandan çık",
+ "toggle": "Tam ekran modu aç/kapat",
+ "fullscreen": "Tam ekran"
+ },
+ "function": {
+ "function": "Fonksiyon"
+ },
+ "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, 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ı",
+ "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": "Ana sayfa",
+ "profile": "Profil",
+ "logout": "Çıkış",
+ "menu": "Menü",
+ "avatar": "Avatar",
+ "open-user-menu": "Kullanıcı menüsünü aç"
+ },
+ "import": {
+ "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çildi"
+ },
+ "js-func": {
+ "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": {
+ "key": "Anahtar",
+ "value": "Değer",
+ "remove-entry": "Girişi kaldır",
+ "add-entry": "Giriş ekle",
+ "no-data": "Giriş yok"
+ },
+ "layout": {
+ "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 düzen seç"
+ },
+ "legend": {
+ "position": "Lejant konumu",
+ "show-max": "Maksimum değeri göster",
+ "show-min": "Minimum değeri göster",
+ "show-avg": "Ortalama değeri göster",
+ "show-total": "Toplam değeri göster",
+ "settings": "Lejant ayarları",
+ "min": "min",
+ "max": "maks",
+ "avg": "ort.",
+ "total": "toplam"
+ },
+ "login": {
+ "login": "Oturum aç",
+ "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": "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": "Sol",
+ "right": "Sağ"
+ },
+ "profile": {
+ "profile": "Profil",
+ "change-password": "Şifre değiştir",
+ "current-password": "Şimdiki şifre"
+ },
+ "relation": {
+ "relations": "İlişkiler",
+ "direction": "Yönelim",
+ "search-direction": {
+ "FROM": "KAYNAK",
+ "TO": "HEDEF"
+ },
+ "direction-type": {
+ "FROM": "kaynak",
+ "TO": "hedef"
+ },
+ "from-relations": "Giden ilişkiler",
+ "to-relations": "Gelen ilişkiler",
+ "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ı",
+ "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ş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, 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, 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",
+ "any-relation": "Herhangi bir ilişki",
+ "relation-filters": "İlişki filtreleri",
+ "additional-info": "Ek bilgi (JSON)",
+ "invalid-additional-info": "Ek bilgi JSON'ı parse edilip işlenemedi."
+ },
+ "rulechain": {
+ "rulechain": "Kural",
+ "rulechains": "Kurallar",
+ "root": "Kök",
+ "delete": "Kuralı sil",
+ "name": "İsim",
+ "name-required": "İsim gerekli.",
+ "description": "Açıklama",
+ "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": "'{{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, 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ı",
+ "rulechain-details": "Kural detayları",
+ "details": "Detaylar",
+ "events": "Olaylar",
+ "system": "Sistem",
+ "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"
+ },
+ "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": "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, 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.",
+ "description": "Açıklama",
+ "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": "{ 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": "Saat",
+ "minutes": "Dakika",
+ "seconds": "Saniye",
+ "advanced": "İleri düzey"
+ },
+ "timewindow": {
+ "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",
+ "last-prefix": "son",
+ "period": "{{ startTime }}'dan {{ endTime }}'a kadar",
+ "edit": "Zaman aralığını düzenle",
+ "date-range": "Tarih aralığı",
+ "last": "Son",
+ "time-period": "Zaman periyodu"
+ },
+ "user": {
+ "user": "Kullanıcı",
+ "users": "Kullanıcılar",
+ "customer-users": "Kullanıcılar",
+ "tenant-admins": "Tenant Adminleri",
+ "sys-admin": "Sistem yöneticisi",
+ "tenant-admin": "Tenant yöneticisi",
+ "customer": "Kullanıcı Grubu",
+ "anonymous": "Anonim",
+ "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": "'{{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, 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",
+ "email": "E-posta",
+ "email-required": "E-posta gerekli.",
+ "invalid-email-format": "Geçersiz e-posta formatı.",
+ "first-name": "Ad",
+ "last-name": "Soyad",
+ "description": "Açıklama",
+ "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ı.",
+ "user-required": "Kullanıcı gerekli",
+ "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 <a href='{{activationLink}}' target='_blank'>bağlantıyı</a> kullanın:",
+ "copy-activation-link": "Etkinleştirme bağlantısını kopyala",
+ "activation-link-copied-message": "Kullanıcı hesabı etkinleştirme bağlantısı panoya kopyalandı",
+ "details": "Ayrıntılar",
+ "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": "String",
+ "string-value": "String değeri",
+ "integer": "Integer",
+ "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": "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.<br>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 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": "Tertiple",
+ "css": "CSS",
+ "settings-schema": "Ayarlar şeması",
+ "datakey-settings-schema": "Veri anahtarı ayarları şeması",
+ "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": "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 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": "Ş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 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, 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.",
+ "system": "Sistem",
+ "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 düzey",
+ "title": "Başlık",
+ "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": "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": "Sıra",
+ "height": "Yükseklik",
+ "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": "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",
+ "add-datasource": "Veri kaynağı ekle",
+ "target-device": "Hedef aygıt",
+ "alarm-source": "Alarm kaynağı",
+ "actions": "Eylemler",
+ "action": "Eylem",
+ "add-action": "Eylem ekle",
+ "search-actions": "Eylem ara",
+ "action-source": "Eylem kaynağı",
+ "action-source-required": "Eylem kaynağı gerekli.",
+ "action-name": "İsim",
+ "action-name-required": "Eylem ismi gerekli.",
+ "action-name-not-unique": "Aynı ada sahip başka bir işlem zaten var.<br/>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.",
+ "edit-action": "Eylemi düzenle",
+ "delete-action": "Eylemi sil",
+ "delete-action-title": "Gösterge eylemini sil",
+ "delete-action-text": "'{{actionName}}' isimli gösterge eylemini silmek istediğinizden emin misiniz?"
+ },
+ "widget-type": {
+ "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": "İkon",
+ "select-icon": "İkon seç",
+ "material-icons": "Material konları",
+ "show-all": "Tüm ikonları göster"
+ },
+ "custom": {
+ "widget-action": {
+ "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": {
+ "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_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 042d230..bac1706 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_TR": "土耳其"
}
}
}
ui/src/app/rulechain/rulechain.scss 16(+1 -15)
diff --git a/ui/src/app/rulechain/rulechain.scss b/ui/src/app/rulechain/rulechain.scss
index 8f77300..d6d264d 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/rulechain/script/node-script-test.scss b/ui/src/app/rulechain/script/node-script-test.scss
index 1dd174d..e68691d 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/rulechain/script/node-script-test.service.js b/ui/src/app/rulechain/script/node-script-test.service.js
index 81d81c1..d4e6df8 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/rulechain/script/node-script-test.tpl.html b/ui/src/app/rulechain/script/node-script-test.tpl.html
index e5fb9c3..e7f50cc 100644
--- a/ui/src/app/rulechain/script/node-script-test.tpl.html
+++ b/ui/src/app/rulechain/script/node-script-test.tpl.html
@@ -98,7 +98,7 @@
validate-content="false"
ng-readonly="true"
fill-height="true">
- </tb-json-content>generateReport
+ </tb-json-content>
</div>
</div>
</div>
ui/src/app/services/menu.service.js 52(+42 -10)
diff --git a/ui/src/app/services/menu.service.js b/ui/src/app/services/menu.service.js
index 1c33d1f..5a7add7 100644
--- a/ui/src/app/services/menu.service.js
+++ b/ui/src/app/services/menu.service.js
@@ -168,6 +168,12 @@ function Menu(userService, $state, $rootScope) {
icon: 'devices_other'
},
{
+ name: 'entity-view.entity-views',
+ type: 'link',
+ state: 'home.entityViews',
+ icon: 'view_quilt'
+ },
+ {
name: 'widget.widget-library',
type: 'link',
state: 'home.widgets-bundles',
@@ -228,6 +234,16 @@ function Menu(userService, $state, $rootScope) {
]
},
{
+ name: 'entity-view.management',
+ places: [
+ {
+ name: 'entity-view.entity-views',
+ icon: 'view_quilt',
+ state: 'home.entityViews'
+ }
+ ]
+ },
+ {
name: 'dashboard.management',
places: [
{
@@ -274,6 +290,12 @@ function Menu(userService, $state, $rootScope) {
icon: 'devices_other'
},
{
+ name: 'entity-view.entity-views',
+ type: 'link',
+ state: 'home.entityViews',
+ icon: 'view_quilt'
+ },
+ {
name: 'dashboard.dashboards',
type: 'link',
state: 'home.dashboards',
@@ -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_quilt',
+ state: 'home.entityViews'
+ }
+ ]
+ },
+ {
+ name: 'dashboard.view-dashboards',
+ places: [
+ {
+ name: 'dashboard.dashboards',
+ icon: 'dashboard',
+ state: 'home.dashboards'
+ }
+ ]
+ }];
}
}
}
diff --git a/ui/src/app/user/add-user.controller.js b/ui/src/app/user/add-user.controller.js
index 20ad0be..c81cd67 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();
ui/src/app/user/user.controller.js 2(+1 -1)
diff --git a/ui/src/app/user/user.controller.js b/ui/src/app/user/user.controller.js
index 8d7f214..ef7948d 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
});
}
ui/src/app/widget/lib/alarms-table-widget.js 192(+177 -15)
diff --git a/ui/src/app/widget/lib/alarms-table-widget.js b/ui/src/app/widget/lib/alarms-table-widget.js
index 0696a7b..3d91e9a 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) {
@@ -341,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);
}
@@ -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 2922996..a031523 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 8480058..39843e1 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 @@
<table md-table md-row-select="vm.enableSelection" multiple="" ng-model="vm.selectedAlarms">
<thead md-head md-order="vm.query.order" md-on-reorder="vm.onReorder">
<tr md-row>
- <th md-column md-order-by="{{ key.name }}" ng-repeat="key in vm.alarmSource.dataKeys"><span>{{ key.title }}</span></th>
- <th md-column ng-if="vm.displayDetails"><span> </span></th>
- <th md-column ng-if="vm.actionCellDescriptors.length"><span> </span></th>
+ <th ng-if="key.display" md-column md-order-by="{{ key.name }}" ng-repeat="key in vm.alarmSource.dataKeys"><span>{{ key.title }}</span></th>
+ <th md-column class="tb-action-cell" layout="row" layout-align="end center">
+ <md-button class="md-icon-button"
+ aria-label="{{'alarm.alarm-status-filter' | translate}}"
+ ng-click="vm.editAlarmStatusFilter($event)">
+ <md-icon aria-label="{{'alarm.alarm-status-filter' | translate}}"
+ class="material-icons">filter_list
+ </md-icon>
+ <md-tooltip md-direction="top">
+ {{'alarm.alarm-status-filter' | translate}}
+ </md-tooltip>
+ </md-button>
+ <md-button class="md-icon-button"
+ aria-label="{{'entity.columns-to-display' | translate}}"
+ ng-click="vm.editColumnsToDisplay($event)">
+ <md-icon aria-label="{{'entity.columns-to-display' | translate}}"
+ class="material-icons">view_column
+ </md-icon>
+ <md-tooltip md-direction="top">
+ {{'entity.columns-to-display' | translate}}
+ </md-tooltip>
+ </md-button>
+ </th>
</tr>
</thead>
<tbody md-body>
<tr ng-show="vm.alarms.length" md-row md-select="alarm"
md-select-id="id.id" md-auto-select="false" ng-repeat="alarm in vm.alarms"
ng-click="vm.onRowClick($event, alarm)" ng-class="{'tb-current': vm.isCurrent(alarm)}">
- <td md-cell flex ng-repeat="key in vm.alarmSource.dataKeys"
+ <td ng-if="key.display" md-cell flex ng-repeat="key in vm.alarmSource.dataKeys"
ng-style="vm.cellStyle(alarm, key)"
ng-bind-html="vm.cellContent(alarm, key)">
</td>
- <td md-cell ng-if="vm.displayDetails" class="tb-action-cell">
- <md-button class="md-icon-button" aria-label="{{ 'alarm.details' | translate }}"
- ng-click="vm.openAlarmDetails($event, alarm)" ng-disabled="$root.loading">
- <md-icon aria-label="{{ 'alarm.details' | translate }}" class="material-icons">more_horiz</md-icon>
- <md-tooltip md-direction="top">
- {{ 'alarm.details' | translate }}
- </md-tooltip>
- </md-button>
- </td>
- <td md-cell ng-if="vm.actionCellDescriptors.length" class="tb-action-cell"
+ <td md-cell class="tb-action-cell"
ng-style="{minWidth: vm.actionCellDescriptors.length*36+'px',
maxWidth: vm.actionCellDescriptors.length*36+'px',
width: vm.actionCellDescriptors.length*36+'px'}">
<md-button class="md-icon-button" ng-repeat="actionDescriptor in vm.actionCellDescriptors"
+ ng-disabled="!vm.actionEnabled(alarm, actionDescriptor)"
aria-label="{{ actionDescriptor.displayName }}"
ng-click="vm.onActionButtonClick($event, alarm, actionDescriptor)" ng-disabled="$root.loading">
<md-icon aria-label="{{ actionDescriptor.displayName }}" class="material-icons">{{actionDescriptor.icon}}</md-icon>
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 0000000..45e8414
--- /dev/null
+++ b/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html
@@ -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.
+
+-->
+
+<md-content style="height: 100%" flex layout="column" class="md-padding">
+ <label class="tb-title" translate>alarm.alarm-status-filter</label>
+ <md-radio-group ng-model="vm.subscription.alarmSearchStatus" class="md-primary">
+ <md-radio-button ng-value="searchStatus"
+ aria-label="{{ ('alarm.search-status.' + searchStatus) | translate }}"
+ class="md-primary md-align-top-left md-radio-interactive" ng-repeat="searchStatus in vm.types.alarmSearchStatus">
+ {{ ('alarm.search-status.' + searchStatus) | translate }}
+ </md-radio-button>
+ </md-radio-group>
+</md-content>
diff --git a/ui/src/app/widget/lib/canvas-digital-gauge.js b/ui/src/app/widget/lib/canvas-digital-gauge.js
index 283a426..274cc97 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;
}
diff --git a/ui/src/app/widget/lib/CanvasDigitalGauge.js b/ui/src/app/widget/lib/CanvasDigitalGauge.js
index ee8b0ed..331ce6c 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
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 0000000..42e2471
--- /dev/null
+++ b/ui/src/app/widget/lib/display-columns-panel.tpl.html
@@ -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.
+
+-->
+
+<md-content style="height: 100%" flex layout="column" class="md-padding">
+ <label class="tb-title" translate>entity.columns-to-display</label>
+ <md-checkbox aria-label="{{ 'entity.columns-to-display' | translate }}" ng-repeat="column in vm.columns"
+ ng-model="column.display">{{ column.title }}
+ </md-checkbox>
+</md-content>
ui/src/app/widget/lib/entities-table-widget.js 95(+83 -12)
diff --git a/ui/src/app/widget/lib/entities-table-widget.js b/ui/src/app/widget/lib/entities-table-widget.js
index d0b629d..6cfe7a0 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;
@@ -367,7 +371,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 +381,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 {};
}
@@ -414,12 +412,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 +452,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) {
@@ -477,11 +536,16 @@ 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';
vm.columnWidth[dataKey.label] = columnWidth;
+
+ dataKey.display = true;
+ vm.columns.push(dataKey);
}
for (var i=0;i<vm.datasources.length;i++) {
@@ -511,4 +575,11 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra
}
-}
\ No newline at end of file
+}
+
+/*@ngInject*/
+function DisplayColumnsPanelController(columns) { //eslint-disable-line
+
+ var vm = this;
+ vm.columns = columns;
+}
diff --git a/ui/src/app/widget/lib/entities-table-widget.scss b/ui/src/app/widget/lib/entities-table-widget.scss
index 85648d6..d745529 100644
--- a/ui/src/app/widget/lib/entities-table-widget.scss
+++ b/ui/src/app/widget/lib/entities-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/entities-table-widget.tpl.html b/ui/src/app/widget/lib/entities-table-widget.tpl.html
index 474d536..66932a0 100644
--- a/ui/src/app/widget/lib/entities-table-widget.tpl.html
+++ b/ui/src/app/widget/lib/entities-table-widget.tpl.html
@@ -41,23 +41,30 @@
<table md-table>
<thead md-head md-order="vm.query.order" md-on-reorder="vm.onReorder">
<tr md-row>
- <th md-column ng-if="vm.displayEntityName" md-order-by="entityName"><span>{{vm.entityNameColumnTitle}}</span></th>
- <th md-column ng-if="vm.displayEntityType" md-order-by="entityType"><span translate>entity.entity-type</span></th>
- <th md-column md-order-by="{{ key.name }}" ng-repeat="key in vm.dataKeys"><span>{{ key.title }}</span></th>
- <th md-column ng-if="vm.actionCellDescriptors.length"><span> </span></th>
+ <th ng-if="column.display" md-column md-order-by="{{ column.name }}" ng-repeat="column in vm.columns"><span>{{ column.title }}</span></th>
+ <th md-column class="tb-action-cell" layout="row" layout-align="end center">
+ <md-button class="md-icon-button"
+ aria-label="{{'entity.columns-to-display' | translate}}"
+ ng-click="vm.editColumnsToDisplay($event)">
+ <md-icon aria-label="{{'entity.columns-to-display' | translate}}"
+ class="material-icons">view_column
+ </md-icon>
+ <md-tooltip md-direction="top">
+ {{'entity.columns-to-display' | translate}}
+ </md-tooltip>
+ </md-button>
+ </th>
</tr>
</thead>
<tbody md-body>
<tr ng-show="vm.entities.length" md-row md-select="entity"
md-select-id="id.id" md-auto-select="false" ng-repeat="entity in vm.entities"
ng-click="vm.onRowClick($event, entity)" ng-class="{'tb-current': vm.isCurrent(entity)}">
- <td md-cell flex ng-if="vm.displayEntityName">{{entity.entityName}}</td>
- <td md-cell flex ng-if="vm.displayEntityType">{{entity.entityType}}</td>
- <td md-cell flex ng-repeat="key in vm.dataKeys"
- ng-style="vm.cellStyle(entity, key)"
- ng-bind-html="vm.cellContent(entity, key)">
+ <td ng-if="column.display" md-cell flex ng-repeat="column in vm.columns"
+ ng-style="vm.cellStyle(entity, column)"
+ ng-bind-html="vm.cellContent(entity, column)">
</td>
- <td md-cell ng-if="vm.actionCellDescriptors.length" class="tb-action-cell"
+ <td md-cell class="tb-action-cell"
ng-style="{minWidth: vm.actionCellDescriptors.length*36+'px',
maxWidth: vm.actionCellDescriptors.length*36+'px',
width: vm.actionCellDescriptors.length*36+'px'}">
ui/src/app/widget/lib/flot-widget.js 458(+245 -213)
diff --git a/ui/src/app/widget/lib/flot-widget.js b/ui/src/app/widget/lib/flot-widget.js
index 1f363d6..acaaf10 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() {
ui/src/app/widget/lib/google-map.js 2(+1 -1)
diff --git a/ui/src/app/widget/lib/google-map.js b/ui/src/app/widget/lib/google-map.js
index 25bf510..2fe7ce4 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
});
ui/src/app/widget/lib/rpc/knob.scss 2(+0 -2)
diff --git a/ui/src/app/widget/lib/rpc/knob.scss b/ui/src/app/widget/lib/rpc/knob.scss
index bf2d3e0..722275f 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 b1da5c6..3083e72 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));
}
}
ui/src/app/widget/lib/rpc/round-switch.scss 95(+41 -54)
diff --git a/ui/src/app/widget/lib/rpc/round-switch.scss b/ui/src/app/widget/lib/rpc/round-switch.scss
index 2e37f63..c89b26b 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;
@@ -69,21 +71,14 @@ $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;
- @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;
@@ -95,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 {
@@ -103,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 {
@@ -125,90 +120,82 @@ $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;
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;
- @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{
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;
- @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{
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),
- -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/lib/timeseries-table-widget.js b/ui/src/app/widget/lib/timeseries-table-widget.js
index bca7956..d025c1e 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;
}
ui/src/app/widget/widget-editor.scss 2(+1 -1)
diff --git a/ui/src/app/widget/widget-editor.scss b/ui/src/app/widget/widget-editor.scss
index cd374e3..0eb37af 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;
}
ui/src/scss/animations.scss 16(+8 -8)
diff --git a/ui/src/scss/animations.scss b/ui/src/scss/animations.scss
index 4ecfc34..61b9bac 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%);
}
}
ui/src/scss/main.scss 28(+11 -17)
diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss
index 27c2d3b..c1a437a 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 {
@@ -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,
@@ -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 {
@@ -556,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,
@@ -652,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;
}
/***********************
@@ -669,24 +663,24 @@ section.tb-footer-buttons {
position: fixed;
right: 20px;
bottom: 20px;
- z-index: 13;
+ z-index: 30;
pointer-events: none;
}
.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);
}
/***********************
ui/src/scss/mixins.scss 2(+2 -0)
diff --git a/ui/src/scss/mixins.scss b/ui/src/scss/mixins.scss
index a754538..4d2c604 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;